diff --git a/.changeset/nasty-lizards-refuse.md b/.changeset/nasty-lizards-refuse.md
new file mode 100644
index 0000000000..80adf28a82
--- /dev/null
+++ b/.changeset/nasty-lizards-refuse.md
@@ -0,0 +1,20 @@
+---
+"@lynx-js/web-elements": patch
+"@lynx-js/web-core": patch
+---
+
+feat: add x-markdown support
+
+Add opt-in support for the `x-markdown` element on Lynx Web, including
+Markdown rendering together with its related styling, interaction, animation,
+truncation, range rendering, and effect capabilities exposed through the
+component API.
+
+Update the `web-core`, `web-core-wasm`, and `web-mainthread-apis` runtime
+paths to use the shared property-or-attribute setter from `web-constants`, so
+custom elements such as `x-markdown` can receive structured property values
+correctly instead of being forced through string-only attribute updates.
+
+```javascript
+import '@lynx-js/web-elements/XMarkdown';
+```
diff --git a/packages/web-platform/web-core-e2e/tests/reactlynx.spec.ts b/packages/web-platform/web-core-e2e/tests/reactlynx.spec.ts
index 063ffce42e..46611245b6 100644
--- a/packages/web-platform/web-core-e2e/tests/reactlynx.spec.ts
+++ b/packages/web-platform/web-core-e2e/tests/reactlynx.spec.ts
@@ -3199,12 +3199,16 @@ test.describe('reactlynx3 tests', () => {
// input/bindinput test-case start
test('basic-element-x-input-bindinput', async ({ page }, { title }) => {
await goto(page, title);
- await page.locator('input').press('Enter');
- await wait(200);
- await page.locator('input').fill('foobar');
- await wait(200);
- const result = await page.locator('.result').first().innerText();
- expect(result).toBe('foobar-6-6');
+ const input = page.locator('input');
+ const result = page.locator('.result').first();
+
+ // Firefox CI can be slower to finish mounting/binding handlers; wait for initial render first.
+ await expect(input).toBeVisible();
+ await expect(input).toHaveValue('bindinput');
+
+ await input.press('Enter');
+ await input.fill('foobar');
+ await expect(result).toHaveText('foobar-6-6');
});
// input/bindinput test-case start for
test('basic-element-input-bindinput', async ({ page }, { title }) => {
diff --git a/packages/web-platform/web-core/ts/client/mainthread/crossThreadHandlers/registerSetNativePropsHandler.ts b/packages/web-platform/web-core/ts/client/mainthread/crossThreadHandlers/registerSetNativePropsHandler.ts
index bc684fa4db..18997903a8 100644
--- a/packages/web-platform/web-core/ts/client/mainthread/crossThreadHandlers/registerSetNativePropsHandler.ts
+++ b/packages/web-platform/web-core/ts/client/mainthread/crossThreadHandlers/registerSetNativePropsHandler.ts
@@ -1,14 +1,15 @@
// Copyright 2023 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
-import type { Rpc } from '@lynx-js/web-worker-rpc';
+import { Rpc } from '@lynx-js/web-worker-rpc';
import { queryNodes } from './queryNodes.js';
-import { setNativePropsEndpoint } from '../../endpoints.js';
import type { LynxViewInstance } from '../LynxViewInstance.js';
+import { setElementPropertyOrAttribute } from '../utils/setElementPropertyOrAttribute.js';
+import { setNativePropsEndpoint } from '../../endpoints.js';
function applyNativeProps(element: Element, nativeProps: Record) {
for (const key in nativeProps) {
- const value = nativeProps[key] as string;
+ const value = nativeProps[key];
if (key === 'text' && element?.tagName === 'X-TEXT') {
if (
element.firstElementChild
@@ -23,7 +24,7 @@ function applyNativeProps(element: Element, nativeProps: Record) {
) {
(element as HTMLElement).style.setProperty(key, value);
} else {
- element.setAttribute(key, value);
+ setElementPropertyOrAttribute(element, key, value);
}
}
}
diff --git a/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts b/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts
index a5b3bc549f..6645046f6c 100644
--- a/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts
+++ b/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts
@@ -1,4 +1,5 @@
import { wasmInstance } from '../../wasm.js';
+import { setElementPropertyOrAttribute } from '../utils/setElementPropertyOrAttribute.js';
import {
AnimationOperation,
@@ -447,11 +448,7 @@ export function createElementAPI(
}
});
} else {
- if (value == null) {
- element.removeAttribute(name);
- } else {
- element.setAttribute(name, value.toString());
- }
+ setElementPropertyOrAttribute(element, name, value);
if (name === 'exposure-id') {
if (value != null) {
mtsBinding.markExposureRelatedElementByUniqueId(
diff --git a/packages/web-platform/web-core/ts/client/mainthread/utils/setElementPropertyOrAttribute.ts b/packages/web-platform/web-core/ts/client/mainthread/utils/setElementPropertyOrAttribute.ts
new file mode 100644
index 0000000000..a710331933
--- /dev/null
+++ b/packages/web-platform/web-core/ts/client/mainthread/utils/setElementPropertyOrAttribute.ts
@@ -0,0 +1,25 @@
+// Copyright 2023 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+
+export const setElementPropertyOrAttribute = (
+ element: Element,
+ key: string,
+ value: unknown,
+) => {
+ if (value == null) {
+ element.removeAttribute(key);
+ return;
+ }
+
+ if (
+ key in element
+ && typeof value !== 'string'
+ && typeof value !== 'number'
+ && typeof value !== 'boolean'
+ ) {
+ (element as unknown as Record)[key] = value;
+ } else {
+ element.setAttribute(key, String(value));
+ }
+};
diff --git a/packages/web-platform/web-elements/index.css b/packages/web-platform/web-elements/index.css
index dcdf435448..cd2d2f04a2 100644
--- a/packages/web-platform/web-elements/index.css
+++ b/packages/web-platform/web-elements/index.css
@@ -13,6 +13,7 @@
@import url("./src/elements/XSvg/x-svg.css");
@import url("./src/elements/XImage/x-image.css");
@import url("./src/elements/XInput/x-input.css");
+@import url("./src/elements/XMarkdown/x-markdown.css");
@import url("./src/elements/XOverlayNg/x-overlay-ng.css");
@import url("./src/elements/XRefreshView/x-refresh-view.css");
@import url("./src/elements/XSwiper/x-swiper.css");
diff --git a/packages/web-platform/web-elements/package.json b/packages/web-platform/web-elements/package.json
index d916941f84..e2dae0a045 100644
--- a/packages/web-platform/web-elements/package.json
+++ b/packages/web-platform/web-elements/package.json
@@ -59,6 +59,11 @@
"types": "./dist/elements/XInput/index.d.ts",
"default": "./dist/elements/XInput/index.js"
},
+ "./XMarkdown": {
+ "@lynx-js/source-field": "./src/elements/XMarkdown/index.ts",
+ "types": "./dist/elements/XMarkdown/index.d.ts",
+ "default": "./dist/elements/XMarkdown/index.js"
+ },
"./XOverlayNg": {
"@lynx-js/source-field": "./src/elements/XOverlayNg/index.ts",
"types": "./dist/elements/XOverlayNg/index.d.ts",
@@ -135,11 +140,16 @@
"test:install": "playwright install --with-deps chromium firefox webkit",
"test:update": "playwright test --ui --update-snapshots"
},
+ "dependencies": {
+ "dompurify": "^3.3.1",
+ "markdown-it": "^14.1.0"
+ },
"devDependencies": {
"@lynx-js/playwright-fixtures": "workspace:*",
"@playwright/test": "^1.58.2",
"@rsbuild/core": "catalog:rsbuild",
"@rsbuild/plugin-source-build": "1.0.4",
+ "@types/markdown-it": "^14.1.1",
"nyc": "^17.1.0",
"tslib": "^2.8.1"
},
diff --git a/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdown.ts b/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdown.ts
new file mode 100644
index 0000000000..859e75245c
--- /dev/null
+++ b/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdown.ts
@@ -0,0 +1,425 @@
+/*
+// Copyright 2024 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+*/
+import { Component } from '../../element-reactive/index.js';
+import { CommonEventsAndMethods } from '../common/CommonEventsAndMethods.js';
+import { templateXMarkdown } from '../htmlTemplates.js';
+import {
+ parseMarkdownStyle,
+ serializeMarkdownStyle,
+ XMarkdownAttributes,
+} from './XMarkdownAttributes.js';
+import type { MarkdownStyleConfig } from './XMarkdownAttributes.js';
+
+type SelectionRoot = ShadowRoot & { getSelection?: () => Selection | null };
+type SelectionWithComposedRanges = Selection & {
+ getComposedRanges?: (...args: unknown[]) => StaticRange[];
+};
+
+const getComposedRange = (
+ selection: Selection,
+ shadowRoot: ShadowRoot | null,
+): StaticRange | null => {
+ const getComposedRanges = (selection as SelectionWithComposedRanges)
+ .getComposedRanges;
+ if (!shadowRoot || typeof getComposedRanges !== 'function') return null;
+ try {
+ return getComposedRanges.call(selection, {
+ shadowRoots: [shadowRoot],
+ })[0] ?? null;
+ } catch {
+ try {
+ return getComposedRanges.call(selection, shadowRoot)[0] ?? null;
+ } catch {
+ return null;
+ }
+ }
+};
+
+const createRangeByOffsets = (
+ doc: Document,
+ root: HTMLElement,
+ start: number,
+ end: number,
+): Range | null => {
+ const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ let pos = 0;
+ let startNode: Text | null = null;
+ let startOffset = 0;
+ let endNode: Text | null = null;
+ let endOffset = 0;
+ let node = walker.nextNode() as Text | null;
+
+ while (node) {
+ const len = node.nodeValue?.length ?? 0;
+ if (!startNode && pos + len >= start) {
+ startNode = node;
+ startOffset = start - pos;
+ }
+ if (pos + len >= end) {
+ endNode = node;
+ endOffset = end - pos;
+ break;
+ }
+ pos += len;
+ node = walker.nextNode() as Text | null;
+ }
+
+ if (!startNode) return null;
+ if (!endNode) {
+ endNode = startNode;
+ endOffset = startOffset;
+ }
+
+ const range = doc.createRange();
+ range.setStart(
+ startNode,
+ Math.max(0, Math.min(startOffset, startNode.length)),
+ );
+ range.setEnd(endNode, Math.max(0, Math.min(endOffset, endNode.length)));
+ return range;
+};
+
+const getRenderedPrefixLengthForSourceOffset = (
+ source: string,
+ rendered: string,
+ sourceOffset: number,
+) => {
+ let renderedIndex = 0;
+ const limit = Math.max(0, Math.min(sourceOffset, source.length));
+
+ for (let sourceIndex = 0; sourceIndex < limit; sourceIndex += 1) {
+ if (renderedIndex >= rendered.length) break;
+ if (source[sourceIndex] === rendered[renderedIndex]) {
+ renderedIndex += 1;
+ }
+ }
+
+ return renderedIndex;
+};
+
+const mapSourceRangeToCharRange = (
+ source: string,
+ rendered: string,
+ start: number,
+ end: number,
+): { start: number; end: number } => {
+ const renderedStart = getRenderedPrefixLengthForSourceOffset(
+ source,
+ rendered,
+ start,
+ );
+ const renderedEnd = getRenderedPrefixLengthForSourceOffset(
+ source,
+ rendered,
+ end,
+ );
+ return {
+ start: renderedStart,
+ end: Math.max(renderedStart, renderedEnd),
+ };
+};
+
+const isSelectionNodeInsideHost = (
+ dom: XMarkdown,
+ shadowRoot: ShadowRoot | null,
+ node: Node | null,
+) =>
+ !!node
+ && (
+ node === dom
+ || node === shadowRoot
+ || dom.contains(node)
+ || !!shadowRoot?.contains(node)
+ );
+
+const getRangeInRoot = (
+ dom: XMarkdown,
+ root: HTMLElement,
+ selection: Selection | null,
+): Range | null => {
+ if (!selection) return null;
+ const shadowRoot = dom.shadowRoot as SelectionRoot | null;
+ const sourceRange = getComposedRange(selection, shadowRoot)
+ ?? (selection.rangeCount > 0 ? selection.getRangeAt(0) : null);
+ if (!sourceRange) return null;
+ if (
+ !root.contains(sourceRange.startContainer)
+ || !root.contains(sourceRange.endContainer)
+ ) {
+ if (
+ !isSelectionNodeInsideHost(dom, shadowRoot, sourceRange.startContainer)
+ || !isSelectionNodeInsideHost(dom, shadowRoot, sourceRange.endContainer)
+ || !isSelectionNodeInsideHost(dom, shadowRoot, selection.anchorNode)
+ || !isSelectionNodeInsideHost(dom, shadowRoot, selection.focusNode)
+ ) {
+ return null;
+ }
+ const selectedText = selection.toString();
+ if (!selectedText) {
+ return null;
+ }
+ const start = root.textContent?.indexOf(selectedText) ?? -1;
+ if (start < 0) return null;
+ return createRangeByOffsets(
+ dom.ownerDocument,
+ root,
+ start,
+ start + selectedText.length,
+ );
+ }
+ const range = dom.ownerDocument.createRange();
+ range.setStart(sourceRange.startContainer, sourceRange.startOffset);
+ range.setEnd(sourceRange.endContainer, sourceRange.endOffset);
+ return range;
+};
+
+const getSelectionCandidates = (dom: XMarkdown): Selection[] => {
+ const shadowRoot = dom.shadowRoot as SelectionRoot | null;
+ const shadowSelection =
+ shadowRoot && typeof shadowRoot.getSelection === 'function'
+ ? shadowRoot.getSelection()
+ : null;
+ const documentSelection = dom.ownerDocument.getSelection();
+ return [shadowSelection, documentSelection].filter(
+ (selection, index, selections): selection is Selection =>
+ !!selection && selections.indexOf(selection) === index,
+ );
+};
+
+const getSelectionForRoot = (
+ dom: XMarkdown,
+ root: HTMLElement,
+): { selection: Selection; range: Range } | null => {
+ for (const selection of getSelectionCandidates(dom)) {
+ const range = getRangeInRoot(dom, root, selection);
+ if (range) return { selection, range };
+ }
+ return null;
+};
+
+const getPreferredSelectionTarget = (dom: XMarkdown): Selection | null => {
+ const candidates = getSelectionCandidates(dom);
+ return candidates[0] ?? null;
+};
+
+@Component(
+ 'x-markdown',
+ [CommonEventsAndMethods, XMarkdownAttributes],
+ templateXMarkdown,
+)
+export class XMarkdown extends HTMLElement {
+ static readonly notToFilterFalseAttributes = new Set(['content-complete']);
+
+ #getMarkdownStyle() {
+ return parseMarkdownStyle(this.getAttribute('markdown-style'));
+ }
+
+ #setMarkdownStyle(value: MarkdownStyleConfig | string | null) {
+ const serialized = serializeMarkdownStyle(value);
+ if (serialized === null) {
+ this.removeAttribute('markdown-style');
+ } else {
+ this.setAttribute('markdown-style', serialized);
+ }
+ }
+
+ get markdownStyle(): MarkdownStyleConfig {
+ return this.#getMarkdownStyle();
+ }
+
+ set markdownStyle(value: MarkdownStyleConfig | string | null) {
+ this.#setMarkdownStyle(value);
+ }
+
+ get ['markdown-style'](): MarkdownStyleConfig {
+ return this.#getMarkdownStyle();
+ }
+
+ set ['markdown-style'](value: MarkdownStyleConfig | string | null) {
+ this.#setMarkdownStyle(value);
+ }
+
+ /**
+ * 获取当前渲染内容中的所有图片 URL。
+ */
+ getImages(): string[] {
+ const root = this.shadowRoot?.querySelector(
+ '#markdown-root',
+ ) as HTMLElement | null;
+ if (!root) return [];
+ return Array.from(root.querySelectorAll('img'))
+ .map((img) => img.getAttribute('src') || '')
+ .filter((v) => !!v) as string[];
+ }
+
+ getContent(params?: { start?: number; end?: number }): { content: string } {
+ const content = this.getAttribute('content') ?? '';
+ const s = Math.max(0, params?.start ?? 0);
+ const eInclusive = params?.end ?? (content.length - 1);
+ const e = Math.min(content.length, eInclusive + 1);
+ const slice = e >= s ? content.slice(s, e) : '';
+ return { content: slice };
+ }
+
+ pauseAnimation() {
+ this.setAttribute('animation-paused', 'true');
+ }
+
+ resumeAnimation(params?: { animationStep?: number }) {
+ if (params?.animationStep !== undefined) {
+ this.setAttribute('initial-animation-step', String(params.animationStep));
+ }
+ if (this.getAttribute('animation-type') !== 'typewriter') {
+ this.setAttribute('animation-type', 'typewriter');
+ }
+ const velocity = this.getAttribute('animation-velocity');
+ if (!velocity || Number(velocity) <= 0) {
+ this.setAttribute('animation-velocity', '40');
+ }
+ this.removeAttribute('animation-paused');
+ }
+
+ getSelectedText(): string {
+ const root = this.shadowRoot?.querySelector('#markdown-root') as
+ | HTMLElement
+ | null;
+ if (!root) return '';
+ const result = getSelectionForRoot(this, root);
+ return result?.range.toString() ?? '';
+ }
+
+ getTextBoundingRect(
+ params?: { start?: number; end?: number; indexType?: 'char' | 'source' },
+ ): { boundingRect: DOMRect } | null {
+ const root = this.shadowRoot?.querySelector('#markdown-root') as
+ | HTMLElement
+ | null;
+ if (!root) return null;
+ const doc = this.ownerDocument;
+ const createRangeByChar = (s: number, e: number): Range | null => {
+ const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ let pos = 0;
+ let startNode: Text | null = null;
+ let startOffset = 0;
+ let endNode: Text | null = null;
+ let endOffset = 0;
+ let node = walker.nextNode() as Text | null;
+ while (node) {
+ const len = node.nodeValue?.length ?? 0;
+ if (!startNode && pos + len >= s) {
+ startNode = node;
+ startOffset = s - pos;
+ }
+ if (pos + len >= e) {
+ endNode = node;
+ endOffset = e - pos;
+ break;
+ }
+ pos += len;
+ node = walker.nextNode() as Text | null;
+ }
+ if (!startNode) return null;
+ if (!endNode) {
+ endNode = startNode;
+ endOffset = startOffset;
+ }
+ const r = doc.createRange();
+ r.setStart(
+ startNode,
+ Math.max(0, Math.min(startOffset, startNode.length)),
+ );
+ r.setEnd(endNode, Math.max(0, Math.min(endOffset, endNode.length)));
+ return r;
+ };
+ if (params?.start !== undefined || params?.end !== undefined) {
+ const s = Math.max(0, params?.start ?? 0);
+ const e = Math.max(s, params?.end ?? s);
+ const rangeOffsets = params?.indexType === 'source'
+ ? mapSourceRangeToCharRange(
+ this.getAttribute('content') ?? '',
+ root.textContent ?? '',
+ s,
+ e,
+ )
+ : { start: s, end: e };
+ if (!rangeOffsets) return null;
+ const r = createRangeByChar(rangeOffsets.start, rangeOffsets.end);
+ if (!r) return null;
+ return { boundingRect: r.getBoundingClientRect() };
+ }
+ const result = getSelectionForRoot(this, root);
+ if (!result) return null;
+ return { boundingRect: result.range.getBoundingClientRect() };
+ }
+
+ setTextSelection(
+ params: { startX: number; startY: number; endX: number; endY: number },
+ ) {
+ const doc = this.ownerDocument as any;
+ const getRangeAtPoint = (x: number, y: number): Range | null => {
+ if (doc.caretRangeFromPoint) return doc.caretRangeFromPoint(x, y);
+ if (doc.caretPositionFromPoint) {
+ const pos = doc.caretPositionFromPoint(x, y);
+ if (!pos) return null;
+ const r = this.ownerDocument.createRange();
+ r.setStart(pos.offsetNode, pos.offset);
+ r.collapse(true);
+ return r;
+ }
+ return null;
+ };
+ const r1 = getRangeAtPoint(params.startX, params.startY);
+ const r2 = getRangeAtPoint(params.endX, params.endY);
+ if (r1 && r2) {
+ const sel = getPreferredSelectionTarget(this);
+ if (!sel) return;
+ sel.removeAllRanges();
+ const range = this.ownerDocument.createRange();
+ range.setStart(r1.startContainer, r1.startOffset);
+ range.setEnd(r2.startContainer, r2.startOffset);
+ sel.addRange(range);
+ }
+ }
+
+ getParseResult(
+ params: { tags: string[] },
+ ): Record {
+ const root = this.shadowRoot?.querySelector('#markdown-root') as
+ | HTMLElement
+ | null;
+ if (!root) return {};
+ const text = root.textContent || '';
+ const result: Record = {};
+ const doc = this.ownerDocument;
+ const calcOffset = (node: Node): { start: number; end: number } => {
+ const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ let pos = 0;
+ let start = -1;
+ let end = -1;
+ let current = walker.nextNode();
+ while (current) {
+ const len = (current.nodeValue || '').length;
+ if (current === node || node.contains(current)) {
+ if (start < 0) {
+ start = pos;
+ }
+ end = pos + len;
+ }
+ pos += len;
+ current = walker.nextNode();
+ }
+ return {
+ start: Math.max(0, Math.min(start, text.length)),
+ end: Math.max(0, Math.min(end, text.length)),
+ };
+ };
+ for (const tag of params.tags) {
+ const nodes = Array.from(root.querySelectorAll(tag));
+ result[tag] = nodes.map((el) => calcOffset(el));
+ }
+ return result;
+ }
+}
diff --git a/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdownAttributes.ts b/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdownAttributes.ts
new file mode 100644
index 0000000000..dfa7b1a1bb
--- /dev/null
+++ b/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdownAttributes.ts
@@ -0,0 +1,1422 @@
+/*
+// Copyright 2024 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+*/
+import type MarkdownIt from 'markdown-it';
+import type createDOMPurify from 'dompurify';
+import {
+ boostedQueueMicrotask,
+ genDomGetter,
+ registerAttributeHandler,
+} from '../../element-reactive/index.js';
+import type { XMarkdown } from './XMarkdown.js';
+
+type MarkdownItCtor = typeof MarkdownIt;
+type DOMPurifyCtor = typeof createDOMPurify;
+
+let MarkdownItLoaded: MarkdownItCtor | undefined;
+let DOMPurifyLoaded: DOMPurifyCtor | undefined;
+let depsLoading: Promise | undefined;
+let depsLoaded = false;
+let depsError: unknown;
+
+const loadDeps = () => {
+ if (depsLoaded) return Promise.resolve();
+ if (depsLoading) return depsLoading;
+ depsLoading = import(
+ /* webpackChunkName: "xmarkdown-deps" */
+ './XMarkdownDeps.js'
+ ).then((deps) => {
+ MarkdownItLoaded = deps.MarkdownIt;
+ DOMPurifyLoaded = deps.createDOMPurify;
+ depsLoaded = true;
+ }).catch((err) => {
+ depsError = err;
+ });
+ return depsLoading;
+};
+
+const escapeHtml = (value: string) =>
+ value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>');
+
+const escapeHtmlAttr = (value: string) =>
+ value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll('\'', ''');
+
+const decodeHtmlEntities = (value: string) =>
+ value
+ .replaceAll('"', '"')
+ .replaceAll('"', '"')
+ .replaceAll(''', '\'')
+ .replaceAll(''', '\'')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>');
+
+const normalizeClassList = (value: string) => {
+ const parts = value.trim().split(/\s+/).filter(Boolean);
+ const safe = parts.filter((part) => /^[A-Za-z0-9_-]+$/.test(part));
+ return safe.join(' ');
+};
+
+const sanitizeAllowedHtml = (value: string) => {
+ if (!value) return value;
+ if (
+ value.includes('
+
+
+
+ web playground
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/events.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/events.html
new file mode 100644
index 0000000000..157f4a1aef
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/events.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+ web playground
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/html-tags.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/html-tags.html
new file mode 100644
index 0000000000..518ebfb199
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/html-tags.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+ web playground
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/image.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/image.html
new file mode 100644
index 0000000000..f7c901fe91
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/image.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+ web playground
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/incremental.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/incremental.html
new file mode 100644
index 0000000000..5c1e32a7f7
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/incremental.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+ web playground
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/inlineview-class.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/inlineview-class.html
new file mode 100644
index 0000000000..f6be0c5dc3
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/inlineview-class.html
@@ -0,0 +1,30 @@
+
+
+
+
+ inlineview class
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/inlineview.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/inlineview.html
new file mode 100644
index 0000000000..03259fbc6c
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/inlineview.html
@@ -0,0 +1,26 @@
+
+
+
+
+ inlineview
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/menu.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/menu.html
new file mode 100644
index 0000000000..2dda35b2a9
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/menu.html
@@ -0,0 +1,203 @@
+
+
+
+
+
+ web playground
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/style.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/style.html
new file mode 100644
index 0000000000..2013fa7eb8
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/style.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+ web playground
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/table.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/table.html
new file mode 100644
index 0000000000..e50ff616e8
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/table.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ web playground
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/text-selection.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/text-selection.html
new file mode 100644
index 0000000000..ba0862b7e1
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/text-selection.html
@@ -0,0 +1,144 @@
+
+
+
+
+
+ web playground
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/truncate.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/truncate.html
new file mode 100644
index 0000000000..adda9a1c90
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/truncate.html
@@ -0,0 +1,25 @@
+
+
+
+
+ truncate
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-cursor.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-cursor.html
new file mode 100644
index 0000000000..820b4cae9f
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-cursor.html
@@ -0,0 +1,39 @@
+
+
+
+
+ typewriter cursor
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-effect.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-effect.html
new file mode 100644
index 0000000000..b263dd9077
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-effect.html
@@ -0,0 +1,52 @@
+
+
+
+
+ typewriter-effect
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-keep-cursor.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-keep-cursor.html
new file mode 100644
index 0000000000..0bfa1d16c4
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-keep-cursor.html
@@ -0,0 +1,44 @@
+
+
+
+
+ typewriter-keep-cursor
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-pause-resume.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-pause-resume.html
new file mode 100644
index 0000000000..969c4183e0
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-pause-resume.html
@@ -0,0 +1,26 @@
+
+
+
+
+ typewriter pause resume
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-reset-content.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-reset-content.html
new file mode 100644
index 0000000000..adaaad49d4
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-reset-content.html
@@ -0,0 +1,60 @@
+
+
+
+
+ typewriter reset content
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-trailing-text.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-trailing-text.html
new file mode 100644
index 0000000000..b395166d5e
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter-trailing-text.html
@@ -0,0 +1,26 @@
+
+
+
+
+ typewriter trailing text
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter.html
new file mode 100644
index 0000000000..36e3692b45
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/typewriter.html
@@ -0,0 +1,30 @@
+
+
+
+
+ typewriter
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-platform/web-elements/tests/x-markdown.spec.ts b/packages/web-platform/web-elements/tests/x-markdown.spec.ts
new file mode 100644
index 0000000000..050e128a41
--- /dev/null
+++ b/packages/web-platform/web-elements/tests/x-markdown.spec.ts
@@ -0,0 +1,846 @@
+import { test, expect } from '@lynx-js/playwright-fixtures';
+import type { Page } from '@playwright/test';
+
+const goto = async (page: Page, fixtureName: string) => {
+ await page.goto(`tests/fixtures/${fixtureName}.html`, { waitUntil: 'load' });
+ await page.evaluate(() => document.fonts.ready);
+};
+
+const getShadowText = async (page: Page, selector: string) =>
+ page.evaluate((value) => {
+ const element = document.querySelector('x-markdown');
+ return element?.shadowRoot?.querySelector(value)?.textContent ?? '';
+ }, selector);
+
+const getShadowCount = async (page: Page, selector: string) =>
+ page.evaluate((value) => {
+ const element = document.querySelector('x-markdown');
+ return element?.shadowRoot?.querySelectorAll(value).length ?? 0;
+ }, selector);
+
+const appendContent = async (page: Page, suffix: string) => {
+ await page.evaluate((value) => {
+ const element = document.querySelector('x-markdown');
+ const content = element?.getAttribute('content') ?? '';
+ element?.setAttribute('content', `${content}${value}`);
+ }, suffix);
+};
+
+const captureFirstChild = async (page: Page) => {
+ await page.evaluate(() => {
+ const element = document.querySelector('x-markdown');
+ const root = element?.shadowRoot?.querySelector('#markdown-root');
+ (window as any).__markdown_first_child = root?.firstElementChild ?? null;
+ });
+};
+
+const isFirstChildSame = async (page: Page) =>
+ page.evaluate(() => {
+ const element = document.querySelector('x-markdown');
+ const root = element?.shadowRoot?.querySelector('#markdown-root');
+ return root?.firstElementChild === (window as any).__markdown_first_child;
+ });
+
+const selectShadowText = async (
+ page: Page,
+ selector: string,
+ startOffset: number,
+ endOffset: number,
+) => {
+ await page.evaluate(
+ ({ value, start, end }) => {
+ (window as any)._selectShadowText(value, start, end);
+ },
+ { value: selector, start: startOffset, end: endOffset },
+ );
+};
+
+const selectShadowTextByMouseup = async (
+ page: Page,
+ selector: string,
+ startOffset: number,
+ endOffset: number,
+) => {
+ await page.evaluate(
+ ({ value, start, end }) => {
+ (window as any)._selectShadowTextByMouseup(value, start, end);
+ },
+ { value: selector, start: startOffset, end: endOffset },
+ );
+};
+
+const setTextSelection = async (
+ page: Page,
+ selector: string,
+ startOffset: number,
+ endOffset: number,
+) => {
+ await page.evaluate(
+ ({ value, start, end }) => {
+ (window as any)._setTextSelection(value, start, end);
+ },
+ { value: selector, start: startOffset, end: endOffset },
+ );
+};
+
+const clearShadowText = async (page: Page) => {
+ await page.evaluate(() => {
+ (window as any)._clearShadowText?.();
+ });
+};
+
+test.describe('x-markdown', () => {
+ test('should render basic markdown', async ({ page }) => {
+ await goto(page, 'x-markdown/basic');
+ const markdown = page.locator('x-markdown');
+
+ await expect(markdown.locator('h1')).toHaveText('Title');
+ await expect(markdown.locator('strong')).toHaveText('bold');
+ await expect(markdown.locator('em')).toHaveText('italic');
+ await expect(markdown.locator('li')).toHaveCount(2);
+ await expect(markdown.locator('code').first()).toContainText('code');
+ });
+
+ test('should apply markdown-style updates', async ({ page }) => {
+ await goto(page, 'x-markdown/style');
+ const markdown = page.locator('x-markdown');
+
+ const link = markdown.locator('a');
+ await expect(link).toHaveCSS('color', 'rgb(255, 0, 0)');
+
+ const inlineCode = markdown.locator('code').first();
+ await expect(inlineCode).toHaveCSS('color', 'rgb(0, 0, 255)');
+ await expect(inlineCode).toHaveCSS(
+ 'background-color',
+ 'rgb(238, 238, 238)',
+ );
+
+ await markdown.evaluate((el) => {
+ el.setAttribute(
+ 'markdown-style',
+ JSON.stringify({ link: { color: '0000ff' } }),
+ );
+ });
+ await expect(link).toHaveCSS('color', 'rgb(0, 0, 255)');
+ });
+
+ test('should support markdown-style property updates', async ({ page }) => {
+ await goto(page, 'x-markdown/style');
+ const markdown = page.locator('x-markdown');
+
+ const link = markdown.locator('a');
+ const propertyValue = await markdown.evaluate((el: any) => {
+ el['markdown-style'] = { link: { color: '00ff00' } };
+ return el.markdownStyle;
+ });
+
+ expect(propertyValue).toEqual({ link: { color: '00ff00' } });
+ await expect(link).toHaveCSS('color', 'rgb(0, 255, 0)');
+ await expect(markdown).toHaveAttribute(
+ 'markdown-style',
+ JSON.stringify({ link: { color: '00ff00' } }),
+ );
+ });
+
+ test('should render image', async ({ page }) => {
+ await goto(page, 'x-markdown/image');
+ const markdown = page.locator('x-markdown');
+
+ const image = markdown.locator('img');
+ await expect(image).toHaveAttribute(
+ 'src',
+ '/tests/fixtures/resources/firefox-logo.png',
+ );
+ });
+
+ test('should update content on attribute change', async ({ page }) => {
+ await goto(page, 'x-markdown/basic');
+ const markdown = page.locator('x-markdown');
+
+ await markdown.evaluate((el) => {
+ el.setAttribute('content', '# Updated');
+ });
+ await expect(markdown.locator('h1')).toHaveText('Updated');
+ });
+
+ test('should only allow span and p html tags in markdown content', async ({ page }) => {
+ await goto(page, 'x-markdown/html-tags');
+ const markdown = page.locator('x-markdown');
+
+ const span = markdown.locator('span.mark-red');
+ await expect(span).toHaveText('World');
+ await expect(span).not.toHaveAttribute('style', /.+/);
+ await expect(span).not.toHaveAttribute('onclick', /.+/);
+
+ const p = markdown.locator('p.mark-help');
+ await expect(p).toHaveText('Paragraph');
+ await expect(p).not.toHaveAttribute('style', /.+/);
+
+ await expect(markdown.locator('div.not-allowed')).toHaveCount(0);
+ await expect(markdown).toContainText(
+ 'Not allowed
',
+ );
+ });
+
+ test('should fire bindlink and bindimageTap events', async ({ page }) => {
+ await goto(page, 'x-markdown/events');
+ const markdown = page.locator('x-markdown');
+
+ await markdown.locator('a').click();
+ await page.waitForFunction(() => (window as any)._bindlink_detail !== null);
+ const linkDetail = await page.evaluate(() =>
+ (window as any)._bindlink_detail
+ );
+ expect(linkDetail.url).toBe('https://example.com');
+ expect(linkDetail.content).toBe('link');
+ expect(linkDetail.contentId).toBe('case-1');
+
+ await markdown.locator('img').click();
+ await page.waitForFunction(() =>
+ (window as any)._bindimage_detail !== null
+ );
+ const imageDetail = await page.evaluate(() =>
+ (window as any)._bindimage_detail
+ );
+ expect(imageDetail.url).toContain(
+ '/tests/fixtures/resources/firefox-logo.png',
+ );
+ expect(imageDetail.contentId).toBe('case-1');
+ });
+
+ test.describe('programmatic shadow selection', () => {
+ test.beforeEach(async ({ browserName }) => {
+ test.skip(
+ browserName === 'webkit',
+ 'programmatic shadow selection is unsupported in webkit',
+ );
+ });
+
+ test('should enable text selection and fire selectionchange', async ({ page }) => {
+ await goto(page, 'x-markdown/text-selection');
+ await page.evaluate(() => {
+ (window as any)._resetSelectionState();
+ document.querySelector('x-markdown')?.setAttribute(
+ 'text-selection',
+ 'true',
+ );
+ });
+
+ const selectionStateBeforeSelect = await page.evaluate(() =>
+ (window as any)._getSelectionState()
+ );
+ expect(selectionStateBeforeSelect.styleText).toContain(
+ 'user-select: text;',
+ );
+
+ await selectShadowText(page, 'h1', 0, 5);
+ await page.waitForFunction(() =>
+ (window as any)._selection_detail !== null
+ );
+
+ const selectionState = await page.evaluate(() =>
+ (window as any)._getSelectionState()
+ );
+
+ expect(selectionState.detail).toMatchObject({
+ start: 0,
+ end: 5,
+ direction: 'forward',
+ });
+ expect(selectionState.selectedText).toBe('Title');
+ expect(selectionState.rect?.width ?? 0).toBeGreaterThan(0);
+ expect(selectionState.rect?.height ?? 0).toBeGreaterThan(0);
+ });
+
+ test('should fire selectionchange on mouseup in web', async ({ page }) => {
+ await goto(page, 'x-markdown/text-selection');
+ await page.evaluate(() => {
+ (window as any)._resetSelectionState();
+ document.querySelector('x-markdown')?.setAttribute(
+ 'text-selection',
+ 'true',
+ );
+ });
+
+ await selectShadowTextByMouseup(page, 'h1', 0, 5);
+ await page.waitForFunction(() =>
+ (window as any)._selection_detail?.end === 5
+ );
+
+ const selectionState = await page.evaluate(() =>
+ (window as any)._getSelectionState()
+ );
+
+ expect(selectionState.detail).toMatchObject({
+ start: 0,
+ end: 5,
+ direction: 'forward',
+ });
+ expect(selectionState.selectedText).toBe('Title');
+ });
+
+ test('should show and position menu on selectionchange', async ({ page }) => {
+ await goto(page, 'x-markdown/menu');
+
+ const initialMenuState = await page.evaluate(() =>
+ (window as any)._getMenuState()
+ );
+ expect(initialMenuState.visibility).toBe('hidden');
+ expect(initialMenuState.labels).toEqual(['全选', '复制']);
+
+ await selectShadowText(page, 'h1', 0, 5);
+ await page.waitForFunction(() =>
+ (window as any)._getMenuState().visibility === 'visible'
+ );
+
+ const menuState = await page.evaluate(() =>
+ (window as any)._getMenuState()
+ );
+ expect(menuState.detail).toMatchObject({
+ start: 0,
+ end: 5,
+ direction: 'forward',
+ });
+ expect(Number.parseFloat(menuState.left)).toBeGreaterThan(0);
+ expect(Number.parseFloat(menuState.top)).toBeGreaterThan(0);
+
+ await clearShadowText(page);
+ await page.waitForFunction(() =>
+ (window as any)._getMenuState().visibility === 'hidden'
+ );
+ });
+
+ test('should defer menu display until mouseup during pointer selection', async ({ page }) => {
+ await goto(page, 'x-markdown/menu');
+
+ await page.locator('x-markdown').dispatchEvent('mousedown');
+ await selectShadowText(page, 'h1', 0, 5);
+
+ const menuStateWhileSelecting = await page.evaluate(() =>
+ (window as any)._getMenuState()
+ );
+ expect(menuStateWhileSelecting.visibility).toBe('hidden');
+
+ await page.evaluate(() => {
+ document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+ });
+ await page.waitForFunction(() =>
+ (window as any)._getMenuState().visibility === 'visible'
+ );
+ });
+
+ test('should clear selection when selection extends outside markdown', async ({ page }) => {
+ await goto(page, 'x-markdown/menu');
+
+ await selectShadowText(page, 'h1', 0, 5);
+ await page.waitForFunction(() =>
+ (window as any)._getMenuState().visibility === 'visible'
+ );
+
+ await page.evaluate(() => {
+ (window as any)._selectShadowTextToMenu();
+ });
+ await page.waitForFunction(() => {
+ const menuState = (window as any)._getMenuState();
+ return (
+ menuState.visibility === 'hidden'
+ && menuState.detail?.start === -1
+ && menuState.detail?.end === -1
+ );
+ });
+
+ const menuState = await page.evaluate(() =>
+ (window as any)._getMenuState()
+ );
+ expect(menuState.selectedText).toBe('');
+ });
+ });
+
+ test('should not fire selectionchange when text-selection is false', async ({ page }) => {
+ await goto(page, 'x-markdown/text-selection');
+ await page.evaluate(() => {
+ (window as any)._resetSelectionState();
+ document.querySelector('x-markdown')?.setAttribute(
+ 'text-selection',
+ 'false',
+ );
+ });
+
+ const selectionStateBeforeSelect = await page.evaluate(() =>
+ (window as any)._getSelectionState()
+ );
+ expect(selectionStateBeforeSelect.styleText).not.toContain(
+ 'user-select: text;',
+ );
+
+ await selectShadowText(page, 'h1', 0, 5);
+ await page.waitForTimeout(50);
+
+ const selectionState = await page.evaluate(() =>
+ (window as any)._getSelectionState()
+ );
+ expect(selectionState.count).toBe(0);
+ });
+
+ test.describe('selection methods', () => {
+ test.beforeEach(async ({ browserName }) => {
+ test.skip(browserName !== 'chromium', 'selection automation is flaky');
+ });
+
+ test('should getSelectedText return current selection text', async ({ page }) => {
+ await goto(page, 'x-markdown/text-selection');
+ await page.evaluate(() => {
+ (window as any)._resetSelectionState();
+ });
+
+ await selectShadowText(page, 'h1', 0, 5);
+ await page.waitForFunction(() =>
+ (window as any)._selection_detail?.end === 5
+ );
+
+ const selectedText = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown') as any;
+ return el.getSelectedText();
+ });
+
+ expect(selectedText).toBe('Title');
+ });
+
+ test('should setTextSelection update current selection', async ({ page }) => {
+ await goto(page, 'x-markdown/text-selection');
+ await setTextSelection(page, 'h1', 0, 5);
+ await page.waitForFunction(() => {
+ const el = document.querySelector('x-markdown') as any;
+ return el?.getSelectedText() === 'Title';
+ });
+
+ const selectionState = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown') as any;
+ return {
+ selectedText: el.getSelectedText(),
+ nativeSelectedText: el.shadowRoot?.getSelection?.()?.toString()
+ ?? document.getSelection()?.toString()
+ ?? '',
+ };
+ });
+
+ expect(selectionState.selectedText).toBe('Title');
+ expect(selectionState.nativeSelectedText).toBe('Title');
+ });
+ });
+
+ test('should append content incrementally', async ({ page }) => {
+ await goto(page, 'x-markdown/basic');
+ expect(await getShadowText(page, 'h1')).toBe('Title');
+ await captureFirstChild(page);
+ await appendContent(page, '\n\n## More');
+ await page.waitForFunction(() => {
+ const element = document.querySelector('x-markdown');
+ const root = element?.shadowRoot;
+ return root?.querySelector('h2')?.textContent === 'More';
+ });
+ expect(await isFirstChildSame(page)).toBe(true);
+ });
+
+ test('should re-render when content diverges', async ({ page }) => {
+ await goto(page, 'x-markdown/basic');
+ await captureFirstChild(page);
+ await page.evaluate(() => {
+ const element = document.querySelector('x-markdown');
+ element?.setAttribute('content', '# Title\n\nReplaced');
+ });
+ await page.waitForFunction(() => {
+ const element = document.querySelector('x-markdown');
+ return element?.shadowRoot?.querySelector('p')?.textContent
+ === 'Replaced';
+ });
+ expect(await isFirstChildSame(page)).toBe(false);
+ });
+
+ test('should batch append by newline boundary', async ({ page }) => {
+ await goto(page, 'x-markdown/incremental');
+ expect(await getShadowCount(page, 'p')).toBe(1);
+ await appendContent(page, 'Line 2');
+ await page.waitForTimeout(20);
+ expect(await getShadowCount(page, 'p')).toBe(1);
+ await page.waitForTimeout(80);
+ expect(await getShadowCount(page, 'p')).toBe(2);
+ });
+
+ test('should render tables', async ({ page }) => {
+ await goto(page, 'x-markdown/table');
+ const markdown = page.locator('x-markdown');
+ await expect(markdown.locator('table')).toHaveCount(1);
+ });
+
+ test('should support basic methods', async ({ page }) => {
+ await goto(page, 'x-markdown/basic');
+ const content = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown') as any;
+ return el.getContent({ start: 0, end: 6 }).content;
+ });
+ expect(content).toBe('# Title');
+ const images = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown') as any;
+ return el.getImages();
+ });
+ expect(Array.isArray(images)).toBeTruthy();
+ });
+
+ test('should return full text ranges for nested markdown tags', async ({ page }) => {
+ await goto(page, 'x-markdown/basic');
+ const content = [
+ 'Hello nested only',
+ '',
+ 'Hello **bold** tail',
+ ].join('\n');
+
+ await page.evaluate((value) => {
+ const el = document.querySelector('x-markdown') as any;
+ el.setAttribute('content', value);
+ }, content);
+ await page.waitForFunction(() => {
+ const el = document.querySelector('x-markdown');
+ const root = el?.shadowRoot?.querySelector('#markdown-root');
+ return root?.textContent?.includes('Hello nested only')
+ && root?.textContent?.includes('Hello bold tail');
+ });
+
+ const ranges = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown') as any;
+ const rendered = el.shadowRoot?.querySelector('#markdown-root')
+ ?.textContent ?? '';
+ return {
+ rendered,
+ result: el.getParseResult({ tags: ['p', 'strong', 'span'] }),
+ };
+ });
+
+ const firstParagraphText = 'Hello nested only';
+ const secondParagraphText = 'Hello bold tail';
+ const boldText = 'bold';
+ const nestedSpanText = 'nested only';
+
+ expect(ranges.result.p).toHaveLength(2);
+ expect(
+ ranges.rendered.slice(ranges.result.p[0].start, ranges.result.p[0].end),
+ ).toBe(firstParagraphText);
+ expect(
+ ranges.rendered.slice(ranges.result.p[1].start, ranges.result.p[1].end),
+ ).toBe(secondParagraphText);
+
+ expect(ranges.result.strong).toHaveLength(1);
+ expect(
+ ranges.rendered.slice(
+ ranges.result.strong[0].start,
+ ranges.result.strong[0].end,
+ ),
+ ).toBe(boldText);
+
+ expect(ranges.result.span).toHaveLength(1);
+ expect(
+ ranges.rendered.slice(
+ ranges.result.span[0].start,
+ ranges.result.span[0].end,
+ ),
+ ).toBe(nestedSpanText);
+ expect(ranges.result.span[0].end).toBeGreaterThan(
+ ranges.result.span[0].start,
+ );
+ });
+
+ test('should support source indices in getTextBoundingRect', async ({ page }) => {
+ await goto(page, 'x-markdown/basic');
+ const rects = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown') as any;
+ const source = el.getAttribute('content') ?? '';
+ const rendered = el.shadowRoot?.querySelector('#markdown-root')
+ ?.textContent ?? '';
+ const sourceToken = '**bold**';
+ const renderedToken = 'bold';
+ const sourceStart = source.indexOf(sourceToken);
+ const charStart = rendered.indexOf(renderedToken);
+ const toPlainRect = (rect: DOMRect | undefined | null) =>
+ rect
+ ? {
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
+ }
+ : null;
+
+ return {
+ sourceRect: toPlainRect(
+ el.getTextBoundingRect({
+ start: sourceStart,
+ end: sourceStart + sourceToken.length,
+ indexType: 'source',
+ })?.boundingRect,
+ ),
+ charRect: toPlainRect(
+ el.getTextBoundingRect({
+ start: charStart,
+ end: charStart + renderedToken.length,
+ indexType: 'char',
+ })?.boundingRect,
+ ),
+ };
+ });
+
+ expect(rects.sourceRect).not.toBeNull();
+ expect(rects.charRect).not.toBeNull();
+ expect(rects.sourceRect?.width).toBeGreaterThan(0);
+ expect(rects.sourceRect?.height).toBeGreaterThan(0);
+ expect(rects.sourceRect?.left).toBeCloseTo(rects.charRect?.left ?? 0, 3);
+ expect(rects.sourceRect?.top).toBeCloseTo(rects.charRect?.top ?? 0, 3);
+ expect(rects.sourceRect?.width).toBeCloseTo(rects.charRect?.width ?? 0, 3);
+ expect(rects.sourceRect?.height).toBeCloseTo(
+ rects.charRect?.height ?? 0,
+ 3,
+ );
+ });
+
+ test('should getImages return image sources', async ({ page }) => {
+ await goto(page, 'x-markdown/image');
+ const images = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown') as any;
+ return el.getImages();
+ });
+ expect(images).toEqual([
+ '/tests/fixtures/resources/firefox-logo.png',
+ ]);
+ });
+
+ test('should inject inline view and keep vertical-align', async ({ page }) => {
+ await goto(page, 'x-markdown/inlineview');
+ await page.waitForFunction(() => {
+ const el = document.querySelector('x-markdown');
+ const root = el && (el as any).shadowRoot;
+ return !!root && !!root.querySelector('.md-inline-view');
+ });
+ const va = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown')!;
+ const root = el.shadowRoot as ShadowRoot;
+ const inline = root.querySelector('.md-inline-view') as
+ | HTMLElement
+ | null;
+ return inline ? getComputedStyle(inline).verticalAlign : '';
+ });
+ expect(va).toBe('middle');
+ });
+
+ test('should apply class styles to inline view', async ({ page }) => {
+ await goto(page, 'x-markdown/inlineview-class');
+ await page.waitForFunction(() => {
+ const el = document.querySelector('x-markdown');
+ const slot = el?.shadowRoot?.querySelector(
+ '.md-inline-view slot',
+ ) as HTMLSlotElement | null;
+ return slot?.assignedElements().some((node) =>
+ (node as HTMLElement).id === 'content-view'
+ ) ?? false;
+ });
+
+ const inlineView = page.locator('#content-view');
+ await expect(inlineView).toHaveCSS('background-color', 'rgb(255, 0, 0)');
+ await expect(inlineView).toHaveCSS('width', '24px');
+ await expect(inlineView).toHaveCSS('height', '24px');
+ });
+
+ test('should clamp and append truncation marker', async ({ page }) => {
+ await goto(page, 'x-markdown/truncate');
+ const markerText = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown')!;
+ const root = el.shadowRoot as ShadowRoot;
+ const marker = root.querySelector('.md-truncation');
+ return marker?.textContent || '';
+ });
+ expect(markerText).toBe('Read more');
+ });
+
+ test('should fire typewriter drawStart/drawEnd', async ({ page }) => {
+ await goto(page, 'x-markdown/typewriter');
+ await page.waitForFunction(() => (window as any)._drawStart === true);
+ await page.waitForFunction(() => (window as any)._drawEnd === true);
+ await expect(page.locator('x-markdown').locator('h1')).toHaveText('Title');
+ });
+
+ test('should pause and resume typewriter animation', async ({ page }) => {
+ await goto(page, 'x-markdown/typewriter-pause-resume');
+ await page.waitForFunction(
+ () => ((window as any)._animationSteps?.length ?? 0) >= 2,
+ );
+
+ const pausedStep = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown') as
+ | (HTMLElement & { pauseAnimation: () => void })
+ | null;
+ el?.pauseAnimation();
+ const steps = (window as any)._animationSteps ?? [];
+ return steps[steps.length - 1] ?? 0;
+ });
+
+ await page.waitForTimeout(300);
+
+ const stepAfterPause = await page.evaluate(() => {
+ const steps = (window as any)._animationSteps ?? [];
+ return steps[steps.length - 1] ?? 0;
+ });
+ expect(stepAfterPause).toBe(pausedStep);
+
+ await page.evaluate(() => {
+ const el = document.querySelector('x-markdown') as
+ | (HTMLElement & { resumeAnimation: () => void })
+ | null;
+ el?.resumeAnimation();
+ });
+
+ await page.waitForFunction(
+ (step) => {
+ const steps = (window as any)._animationSteps ?? [];
+ return (steps[steps.length - 1] ?? 0) > step;
+ },
+ pausedStep,
+ );
+ });
+
+ test('should reset typewriter state when content is cleared and reassigned', async ({ page }) => {
+ await goto(page, 'x-markdown/typewriter-reset-content');
+ await page.waitForFunction(() => (window as any)._reassigned === true);
+ await page.waitForFunction(
+ () => ((window as any)._newAnimationSteps?.length ?? 0) >= 1,
+ );
+
+ const state = await page.evaluate(() => ({
+ textAfterReassign: (window as any)._textAfterReassign,
+ firstNewStep: (window as any)._newAnimationSteps?.[0] ?? null,
+ }));
+
+ expect(state.textAfterReassign).toBe('');
+ expect(state.firstNewStep).toBe(1);
+ });
+
+ test('should render custom typewriter cursor', async ({ page }) => {
+ await goto(page, 'x-markdown/typewriter-cursor');
+ await page.waitForFunction(() => (window as any)._drawStart === true);
+ await page.waitForTimeout(1500);
+
+ const cursorRendered = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown')!;
+ const root = el.shadowRoot as ShadowRoot;
+ const cursor = root.querySelector('#cursor');
+ return !!cursor;
+ });
+ expect(cursorRendered).toBe(true);
+ await page.waitForTimeout(1000);
+ const cursor = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown')!;
+ const root = el.shadowRoot as ShadowRoot;
+ const cursor = root.querySelector('#cursor');
+ return cursor;
+ });
+ expect(cursor).toBeNull();
+ });
+
+ test('should render typewriter cursor after trailing text node', async ({ page }) => {
+ await goto(page, 'x-markdown/typewriter-trailing-text');
+ await page.waitForTimeout(500);
+
+ const isCorrectParent = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown')!;
+ const root = el.shadowRoot as ShadowRoot;
+ const cursor = root.querySelector('.md-typewriter-cursor');
+ if (!cursor) return false;
+
+ const parent = cursor.parentElement;
+ return parent && parent.tagName === 'P';
+ });
+
+ expect(isCorrectParent).toBe(true);
+ });
+
+ test('should not hide cursor when content-complete is false', async ({ page }) => {
+ await goto(page, 'x-markdown/typewriter-keep-cursor');
+ await page.waitForFunction(() => (window as any)._drawStart === true);
+ await page.waitForFunction(() =>
+ (window as any)._animationComplete === true
+ );
+ const isComplete = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown')!;
+ return el.getAttribute('content-complete');
+ });
+ expect(isComplete).toBe('false');
+ const cursorRendered = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown')!;
+ const root = el.shadowRoot as ShadowRoot;
+ const cursor = root.querySelector('.md-typewriter-cursor');
+ return !!cursor;
+ });
+ expect(cursorRendered).toBe(true);
+ });
+
+ test('should render markdown effect', async ({ page }) => {
+ await goto(page, 'x-markdown/typewriter-effect');
+ await page.waitForFunction(() => (window as any)._drawStart === true);
+ await page.waitForFunction(() =>
+ (window as any)._animationComplete === true
+ );
+
+ const effectState = await page.evaluate(() => {
+ const el = document.querySelector('x-markdown')!;
+ const root = el.shadowRoot as ShadowRoot;
+ const effects = Array.from(
+ root.querySelectorAll('.md-text-mask-effect'),
+ ) as HTMLElement[];
+ const overlays = Array.from(
+ root.querySelectorAll('.md-text-mask-effect-overlay'),
+ ) as HTMLElement[];
+ const contents = Array.from(
+ root.querySelectorAll('.md-text-mask-effect-content'),
+ ) as HTMLElement[];
+ return {
+ rendered: effects.length > 0,
+ effectCount: effects.length,
+ overlayText: overlays.map((overlay) => overlay.textContent ?? '').join(
+ '',
+ ),
+ contentText: contents.map((content) => content.textContent ?? '').join(
+ '',
+ ),
+ overlayBackgrounds: overlays.map((overlay) => {
+ const styles = getComputedStyle(overlay);
+ return {
+ backgroundImage: styles.backgroundImage,
+ backgroundSize: styles.backgroundSize,
+ backgroundPosition: styles.backgroundPosition,
+ };
+ }),
+ };
+ });
+ expect(effectState.rendered).toBe(true);
+ expect(effectState.effectCount).toBeGreaterThan(0);
+ expect(effectState.overlayText).toBe('TTTT');
+ expect(effectState.contentText).toBe('TTTT');
+ expect(effectState.overlayBackgrounds.length).toBeGreaterThan(0);
+ expect(effectState.overlayBackgrounds[0]?.backgroundImage).toContain(
+ 'linear-gradient',
+ );
+ if (effectState.overlayBackgrounds.length > 1) {
+ expect(
+ new Set(
+ effectState.overlayBackgrounds.map(
+ (overlay) => overlay.backgroundSize,
+ ),
+ ).size,
+ ).toBe(1);
+ expect(
+ new Set(
+ effectState.overlayBackgrounds.map(
+ (overlay) => overlay.backgroundPosition,
+ ),
+ ).size,
+ ).toBeGreaterThan(1);
+ }
+ });
+});
diff --git a/packages/web-platform/web-tests/tests/react.spec.ts b/packages/web-platform/web-tests/tests/react.spec.ts
index c2e7965719..79a7dd30e5 100644
--- a/packages/web-platform/web-tests/tests/react.spec.ts
+++ b/packages/web-platform/web-tests/tests/react.spec.ts
@@ -4606,6 +4606,25 @@ test.describe('reactlynx3 tests', () => {
},
);
});
+ test.describe('x-markdown', () => {
+ test(
+ 'basic-element-x-markdown-markdown-style',
+ async ({ page }, { title }) => {
+ await goto(page, title);
+ await wait(200);
+
+ const markdown = page.locator('x-markdown');
+ await expect(markdown).toHaveAttribute(
+ 'markdown-style',
+ JSON.stringify({ link: { color: '00ff00' } }),
+ );
+ await expect(markdown.locator('a')).toHaveCSS(
+ 'color',
+ 'rgb(0, 255, 0)',
+ );
+ },
+ );
+ });
test.describe('x-audio-tt', () => {
test('basic-element-x-audio-tt-play', async ({ page }, { title }) => {
// test.skip(true, 'lynx.createSelectorQuery is not supported'); // FIXME
diff --git a/packages/web-platform/web-tests/tests/react/basic-element-x-markdown-markdown-style/index.jsx b/packages/web-platform/web-tests/tests/react/basic-element-x-markdown-markdown-style/index.jsx
new file mode 100644
index 0000000000..ce46e83fb5
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-element-x-markdown-markdown-style/index.jsx
@@ -0,0 +1,24 @@
+// Copyright 2023 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { root } from '@lynx-js/react';
+
+const markdownStyle = {
+ link: {
+ color: '00ff00',
+ },
+};
+
+function App() {
+ return (
+
+
+
+ );
+}
+
+root.render();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 38b6b6d39b..165902a068 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1467,6 +1467,13 @@ importers:
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(@vitest/ui@3.2.4)(jsdom@27.4.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)
packages/web-platform/web-elements:
+ dependencies:
+ dompurify:
+ specifier: ^3.3.1
+ version: 3.3.1
+ markdown-it:
+ specifier: ^14.1.0
+ version: 14.1.0
devDependencies:
'@lynx-js/playwright-fixtures':
specifier: workspace:*
@@ -1480,6 +1487,9 @@ importers:
'@rsbuild/plugin-source-build':
specifier: 1.0.4
version: 1.0.4(@rsbuild/core@1.7.5)
+ '@types/markdown-it':
+ specifier: ^14.1.1
+ version: 14.1.1
nyc:
specifier: ^17.1.0
version: 17.1.0
@@ -4492,12 +4502,21 @@ packages:
'@types/jsonfile@6.1.4':
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
+ '@types/linkify-it@5.0.0':
+ resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
+
'@types/loader-utils@2.0.6':
resolution: {integrity: sha512-cgu0Xefgq9O5FjFR78jgI6X31aPjDWCaJ6LCfRtlj6BtyVVWiXagysSYlPACwGKAzRwsFLjKXcj4iGfcVt6cLw==}
+ '@types/markdown-it@14.1.1':
+ resolution: {integrity: sha512-4NpsnpYl2Gt1ljyBGrKMxFYAYvpqbnnkgP/i/g+NLpjEUa3obn1XJCur9YbEXKDAkaXqsR1LbDnGEJ0MmKFxfg==}
+
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+ '@types/mdurl@2.0.0':
+ resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
+
'@types/mdx@2.0.13':
resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==}
@@ -4575,6 +4594,9 @@ packages:
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
+ '@types/trusted-types@2.0.7':
+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
'@types/uglify-js@3.17.5':
resolution: {integrity: sha512-TU+fZFBTBcXj/GpDpDaBmgWk/gn96kMZ+uocaFUlV2f8a6WdMzzI44QBCmGcCiYR0Y6ZlNRiyUyKKt5nl/lbzQ==}
@@ -5894,6 +5916,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
+ dompurify@3.3.1:
+ resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
+
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@@ -7452,6 +7477,9 @@ packages:
resolution: {integrity: sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ linkify-it@5.0.0:
+ resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
+
lint-staged@16.2.7:
resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==}
engines: {node: '>=20.17'}
@@ -7567,6 +7595,10 @@ packages:
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
engines: {node: '>=16'}
+ markdown-it@14.1.0:
+ resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
+ hasBin: true
+
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@@ -7631,6 +7663,9 @@ packages:
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
+ mdurl@2.0.0:
+ resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
+
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -8538,6 +8573,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ punycode.js@2.3.1:
+ resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
+ engines: {node: '>=6'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -9777,6 +9816,9 @@ packages:
peerDependencies:
typescript: '>=4.8.0 <5.10.0'
+ uc.micro@2.1.0:
+ resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
+
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -12980,15 +13022,24 @@ snapshots:
dependencies:
'@types/node': 24.10.13
+ '@types/linkify-it@5.0.0': {}
+
'@types/loader-utils@2.0.6':
dependencies:
'@types/node': 24.10.13
'@types/webpack': 4.41.40
+ '@types/markdown-it@14.1.1':
+ dependencies:
+ '@types/linkify-it': 5.0.0
+ '@types/mdurl': 2.0.0
+
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
+ '@types/mdurl@2.0.0': {}
+
'@types/mdx@2.0.13': {}
'@types/mime@1.3.5': {}
@@ -13063,6 +13114,9 @@ snapshots:
'@types/tough-cookie@4.0.5': {}
+ '@types/trusted-types@2.0.7':
+ optional: true
+
'@types/uglify-js@3.17.5':
dependencies:
source-map: 0.6.1
@@ -14448,6 +14502,10 @@ snapshots:
dependencies:
domelementtype: 2.3.0
+ dompurify@3.3.1:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
@@ -16373,6 +16431,10 @@ snapshots:
lines-and-columns@2.0.4: {}
+ linkify-it@5.0.0:
+ dependencies:
+ uc.micro: 2.1.0
+
lint-staged@16.2.7:
dependencies:
commander: 14.0.3
@@ -16489,6 +16551,15 @@ snapshots:
markdown-extensions@2.0.0: {}
+ markdown-it@14.1.0:
+ dependencies:
+ argparse: 2.0.1
+ entities: 4.5.0
+ linkify-it: 5.0.0
+ mdurl: 2.0.0
+ punycode.js: 2.3.1
+ uc.micro: 2.1.0
+
markdown-table@3.0.4: {}
math-intrinsics@1.1.0: {}
@@ -16671,6 +16742,8 @@ snapshots:
mdn-data@2.12.2: {}
+ mdurl@2.0.0: {}
+
media-typer@0.3.0: {}
media-typer@1.1.0: {}
@@ -17690,6 +17763,8 @@ snapshots:
picocolors: 1.1.1
sade: 1.8.1
+ punycode.js@2.3.1: {}
+
punycode@2.3.1: {}
qs@6.13.0:
@@ -19118,6 +19193,8 @@ snapshots:
randexp: 0.5.3
typescript: 5.9.3
+ uc.micro@2.1.0: {}
+
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4