diff --git a/.changeset/nasty-lizards-refuse.md b/.changeset/nasty-lizards-refuse.md new file mode 100644 index 0000000000..0c340a2142 --- /dev/null +++ b/.changeset/nasty-lizards-refuse.md @@ -0,0 +1,11 @@ +--- +"@lynx-js/web-elements": patch +--- + +feat: add x-markdown support + +The x-markdonw is under opt-in importing pattern. + +```javascript +import '@lynx-js/web-elements/XMarkdown'; +``` diff --git a/.github/lynx-stack.instructions.md b/.github/lynx-stack.instructions.md index 023e0c6ec5..7f78f96edc 100644 --- a/.github/lynx-stack.instructions.md +++ b/.github/lynx-stack.instructions.md @@ -2,7 +2,10 @@ applyTo: "packages/web-platform/web-elements/**/*" --- -When updating web element APIs, add targeted Playwright tests in packages/web-platform/web-elements/tests/web-elements.spec.ts and keep changes minimal. +When updating web element APIs, add targeted Playwright tests in a dedicated spec file under packages/web-platform/web-elements/tests and keep changes minimal. Ensure Playwright browsers are installed (pnpm exec playwright install --with-deps ) before running web-elements tests. + For x-input type="number" in web-elements, keep inner input type as text, set inputmode="decimal", and filter number input internally without setting input-filter explicitly. Add new web-elements UI fixtures under packages/web-platform/web-elements/tests/fixtures and commit matching snapshots in packages/web-platform/web-elements/tests/web-elements.spec.ts-snapshots. + +For x-markdown updates, keep markdown-style injected via a shadow-root style tag and render markdown only on attribute changes. 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 bd0cb582d9..13cd672e04 100644 --- a/packages/web-platform/web-elements/package.json +++ b/packages/web-platform/web-elements/package.json @@ -58,6 +58,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", @@ -134,11 +139,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..c24663389e --- /dev/null +++ b/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdown.ts @@ -0,0 +1,16 @@ +/* +// 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 { XMarkdownAttributes } from './XMarkdownAttributes.js'; + +@Component( + 'x-markdown', + [CommonEventsAndMethods, XMarkdownAttributes], + templateXMarkdown, +) +export class XMarkdown extends HTMLElement {} 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..0071f0eba3 --- /dev/null +++ b/packages/web-platform/web-elements/src/elements/XMarkdown/XMarkdownAttributes.ts @@ -0,0 +1,352 @@ +/* +// 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 createDOMPurify from 'dompurify'; +import MarkdownIt from 'markdown-it'; +import { + boostedQueueMicrotask, + genDomGetter, + registerAttributeHandler, +} from '../../element-reactive/index.js'; +import type { XMarkdown } from './XMarkdown.js'; + +const MarkdownItCtor = + (MarkdownIt as unknown as { default?: typeof MarkdownIt }).default + ?? MarkdownIt; +const DOMPurifyCtor = + (createDOMPurify as unknown as { default?: typeof createDOMPurify }).default + ?? createDOMPurify; +let markdownParser: MarkdownIt | null = null; +let markdownParserError: unknown; +let htmlSanitizer: ReturnType | null = null; +const getMarkdownParser = () => { + if (markdownParser || markdownParserError) return markdownParser; + try { + markdownParser = new MarkdownItCtor({ + html: false, + linkify: true, + }); + } catch (error) { + markdownParserError = error; + } + return markdownParser; +}; + +const getHtmlSanitizer = () => { + if (htmlSanitizer) return htmlSanitizer; + htmlSanitizer = DOMPurifyCtor(window)!; + return htmlSanitizer; +}; + +const sanitizeHtml = (value: string) => { + const sanitizer = getHtmlSanitizer(); + if (!sanitizer) return value; + return sanitizer.sanitize(value, { USE_PROFILES: { html: true } }) as string; +}; + +const unitlessCssProperties = new Set([ + 'font-weight', + 'line-height', + 'opacity', + 'z-index', + 'flex', + 'flex-grow', + 'flex-shrink', + 'order', +]); + +const selectorMap: Record = { + normalText: '.markdown-body', + link: '.markdown-body a', + inlineCode: '.markdown-body code:not(pre code)', + codeBlock: ['.markdown-body pre', '.markdown-body pre code'], + h1: '.markdown-body h1', + h2: '.markdown-body h2', + h3: '.markdown-body h3', + h4: '.markdown-body h4', + h5: '.markdown-body h5', + h6: '.markdown-body h6', + quote: '.markdown-body blockquote', + orderedList: '.markdown-body ol', + unorderedList: '.markdown-body ul', + listItem: '.markdown-body li', + image: '.markdown-body img', + span: '.markdown-body span', + p: '.markdown-body p', +}; + +const normalizeColor = (value: string) => { + const hex = value.trim(); + if (/^[0-9a-fA-F]{6,8}$/.test(hex)) { + return `#${hex}`; + } + return value; +}; + +const camelToKebab = (value: string) => + value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + +const toCssValue = (property: string, value: unknown) => { + if (value === null || value === undefined) return null; + if (typeof value === 'number') { + return unitlessCssProperties.has(property) ? `${value}` : `${value}px`; + } + if (typeof value === 'string') { + if (property.includes('color')) { + return normalizeColor(value); + } + return value; + } + return null; +}; + +const parseMarkdownStyle = (value: string | null) => { + if (!value) return {} as Record>; + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === 'object') { + return parsed as Record>; + } + } catch { + return {} as Record>; + } + return {} as Record>; +}; + +const buildMarkdownStyleCss = ( + style: Record>, +) => { + const rules: string[] = []; + for (const [key, properties] of Object.entries(style)) { + if (!properties || typeof properties !== 'object') continue; + let selectors: string[] = []; + if (key.startsWith('.') || key.startsWith('#')) { + selectors = [`.markdown-body ${key}`]; + } else if (selectorMap[key]) { + selectors = Array.isArray(selectorMap[key]) + ? (selectorMap[key] as string[]) + : [selectorMap[key] as string]; + } else { + continue; + } + const declarations = Object.entries(properties) + .map(([property, value]) => { + const cssProperty = camelToKebab(property); + const cssValue = toCssValue(cssProperty, value); + return cssValue ? `${cssProperty}: ${cssValue};` : null; + }) + .filter(Boolean) + .join(''); + if (!declarations) continue; + for (const selector of selectors) { + rules.push(`${selector} {${declarations}}`); + } + } + return rules.join('\n'); +}; + +export class XMarkdownAttributes { + static observedAttributes = ['content', 'markdown-style', 'content-id']; + + readonly #dom: XMarkdown; + readonly #root = genDomGetter(() => this.#dom.shadowRoot!, '#markdown-root'); + readonly #style = genDomGetter( + () => this.#dom.shadowRoot!, + '#markdown-style', + ); + + #pendingRender = false; + #content = ''; + #renderedContent = ''; + #contentId?: string; + #eventsAttached = false; + #appendRemainder = ''; + #appendFlushTimer?: ReturnType; + #appendFlushDelay = 60; + + constructor(dom: XMarkdown) { + this.#dom = dom; + } + + connectedCallback() { + this.#ensureEvents(); + } + + dispose() { + if (!this.#eventsAttached) return; + const root = this.#root(); + root.removeEventListener('click', this.#handleClick); + this.#eventsAttached = false; + } + + #ensureEvents() { + if (this.#eventsAttached) return; + const root = this.#root(); + root.addEventListener('click', this.#handleClick); + this.#eventsAttached = true; + } + + #handleClick = (event: MouseEvent) => { + const target = event.target as Element | null; + if (!target) return; + const root = this.#root(); + const anchor = target.closest('a'); + if (anchor && root.contains(anchor)) { + event.preventDefault(); + this.#dom.dispatchEvent( + new CustomEvent('bindlink', { + detail: { + url: anchor.getAttribute('href') ?? '', + content: anchor.textContent ?? '', + contentId: this.#contentId, + }, + bubbles: true, + composed: true, + }), + ); + return; + } + const image = target.closest('img'); + if (image && root.contains(image)) { + this.#dom.dispatchEvent( + new CustomEvent('bindimageTap', { + detail: { + url: image.getAttribute('src') ?? '', + contentId: this.#contentId, + }, + bubbles: true, + composed: true, + }), + ); + } + }; + + #scheduleRender() { + if (this.#pendingRender) return; + this.#pendingRender = true; + boostedQueueMicrotask(() => { + this.#pendingRender = false; + this.#render(); + }); + } + + #render() { + const root = this.#root(); + if (!this.#content) { + root.innerHTML = ''; + this.#renderedContent = ''; + this.#appendRemainder = ''; + this.#clearAppendFlushTimer(); + return; + } + const parser = getMarkdownParser(); + if (!parser) { + root.textContent = this.#content; + this.#renderedContent = this.#content; + this.#appendRemainder = ''; + this.#clearAppendFlushTimer(); + return; + } + if (this.#canAppendIncrementally()) { + this.#appendIncrementally(root); + return; + } + const rendered = parser.render(this.#content); + root.innerHTML = sanitizeHtml(rendered); + this.#renderedContent = this.#content; + this.#appendRemainder = ''; + this.#clearAppendFlushTimer(); + } + + #canAppendIncrementally() { + if (!this.#renderedContent) return false; + if (!this.#content.startsWith(this.#renderedContent)) return false; + if (this.#content.length === this.#renderedContent.length) return false; + return true; + } + + #appendIncrementally(root: HTMLElement) { + const delta = this.#content.slice(this.#renderedContent.length); + if (!delta) return; + const lastNewlineIndex = delta.lastIndexOf('\n'); + if (lastNewlineIndex === -1) { + this.#appendRemainder = delta; + this.#scheduleAppendFlush(); + return; + } + const chunk = delta.slice(0, lastNewlineIndex + 1); + if (chunk) { + const parser = getMarkdownParser(); + if (!parser) return; + const html = parser.render(chunk); + this.#appendHtml(root, html); + this.#renderedContent += chunk; + } + this.#appendRemainder = delta.slice(lastNewlineIndex + 1); + if (this.#appendRemainder) { + this.#scheduleAppendFlush(); + } else { + this.#clearAppendFlushTimer(); + } + } + + #scheduleAppendFlush() { + this.#clearAppendFlushTimer(); + this.#appendFlushTimer = setTimeout(() => { + this.#appendFlushTimer = undefined; + this.#flushAppendRemainder(); + }, this.#appendFlushDelay); + } + + #clearAppendFlushTimer() { + if (this.#appendFlushTimer) { + clearTimeout(this.#appendFlushTimer); + this.#appendFlushTimer = undefined; + } + } + + #flushAppendRemainder() { + if (!this.#appendRemainder) return; + if (!this.#content.startsWith(this.#renderedContent)) return; + const expectedLength = this.#renderedContent.length + + this.#appendRemainder.length; + if (this.#content.length !== expectedLength) return; + const root = this.#root(); + const parser = getMarkdownParser(); + if (!parser) return; + const html = parser.render(this.#appendRemainder); + this.#appendHtml(root, html); + this.#renderedContent += this.#appendRemainder; + this.#appendRemainder = ''; + } + + #appendHtml(root: HTMLElement, html: string) { + const template = document.createElement('template'); + template.innerHTML = sanitizeHtml(html); + root.append(template.content); + } + + #applyMarkdownStyle(value: string | null) { + const styleTag = this.#style(); + const parsed = parseMarkdownStyle(value); + styleTag.textContent = buildMarkdownStyleCss(parsed); + } + + @registerAttributeHandler('content', true) + _handleContent(newVal: string | null) { + this.#content = newVal ?? ''; + this.#scheduleRender(); + } + + @registerAttributeHandler('markdown-style', true) + _handleMarkdownStyle(newVal: string | null) { + this.#applyMarkdownStyle(newVal); + } + + @registerAttributeHandler('content-id', true) + _handleContentId(newVal: string | null) { + this.#contentId = newVal ?? undefined; + } +} diff --git a/packages/web-platform/web-elements/src/elements/XMarkdown/index.ts b/packages/web-platform/web-elements/src/elements/XMarkdown/index.ts new file mode 100644 index 0000000000..36b3f5acd3 --- /dev/null +++ b/packages/web-platform/web-elements/src/elements/XMarkdown/index.ts @@ -0,0 +1,11 @@ +// 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. + +/** + * @module elements/XMarkdown + * + * `x-markdown` renders markdown content with minimal styling. + * It supports the `content` and `markdown-style` attributes. + */ +export { XMarkdown } from './XMarkdown.js'; diff --git a/packages/web-platform/web-elements/src/elements/XMarkdown/x-markdown.css b/packages/web-platform/web-elements/src/elements/XMarkdown/x-markdown.css new file mode 100644 index 0000000000..47d6ea22d2 --- /dev/null +++ b/packages/web-platform/web-elements/src/elements/XMarkdown/x-markdown.css @@ -0,0 +1,15 @@ +/* +// 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. +*/ +x-markdown { + display: flex; + flex-direction: column; + align-items: stretch; + color: inherit; +} + +x-markdown::part(root) { + width: 100%; +} diff --git a/packages/web-platform/web-elements/src/elements/htmlTemplates.ts b/packages/web-platform/web-elements/src/elements/htmlTemplates.ts index 6f7a0f0163..dbc8a4583d 100644 --- a/packages/web-platform/web-elements/src/elements/htmlTemplates.ts +++ b/packages/web-platform/web-elements/src/elements/htmlTemplates.ts @@ -104,9 +104,9 @@ export const templateXImage = (attributes: { src?: string }) => { return ` `; }; -export const templateFilterImage = templateXImage; +export const templateFilterImage = /*#__PURE__*/ templateXImage; -export const templateXInput = ` + +
`; + +export const templateInlineImage = /*#__PURE__*/ templateXImage; -export const templateXTextarea = ` `; -export const templateXSvg = () => { +export const templateXSvg = /*#__PURE__*/ () => { return ` `; }; diff --git a/packages/web-platform/web-elements/tests/fixtures/shell-project.ts b/packages/web-platform/web-elements/tests/fixtures/shell-project.ts index a2384b3dfe..df60dfe46b 100644 --- a/packages/web-platform/web-elements/tests/fixtures/shell-project.ts +++ b/packages/web-platform/web-elements/tests/fixtures/shell-project.ts @@ -1,5 +1,6 @@ import '../../src/compat/LinearContainer/LinearContainer.js'; import '../../src/elements/all.js'; +import '../../src/elements/XMarkdown/index.js'; import '../../index.css'; import '@lynx-js/playwright-fixtures/common.css'; import { Component } from '../../src/element-reactive/component.js'; diff --git a/packages/web-platform/web-elements/tests/fixtures/x-markdown/basic.html b/packages/web-platform/web-elements/tests/fixtures/x-markdown/basic.html new file mode 100644 index 0000000000..635e43caef --- /dev/null +++ b/packages/web-platform/web-elements/tests/fixtures/x-markdown/basic.html @@ -0,0 +1,39 @@ + + + + + + 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/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/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/x-markdown.spec.ts b/packages/web-platform/web-elements/tests/x-markdown.spec.ts new file mode 100644 index 0000000000..65a103d80c --- /dev/null +++ b/packages/web-platform/web-elements/tests/x-markdown.spec.ts @@ -0,0 +1,163 @@ +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; + }); + +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 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 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('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); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cacbb74b81..b5ae0a4c4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1258,6 +1258,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)(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:* @@ -1271,6 +1278,9 @@ importers: '@rsbuild/plugin-source-build': specifier: 1.0.4 version: 1.0.4(@rsbuild/core@1.7.3) + '@types/markdown-it': + specifier: ^14.1.1 + version: 14.1.1 nyc: specifier: ^17.1.0 version: 17.1.0 @@ -4320,12 +4330,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==} @@ -4403,6 +4422,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==} @@ -5710,6 +5732,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==} @@ -7177,6 +7202,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'} @@ -7295,6 +7323,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==} @@ -7359,6 +7391,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'} @@ -8250,6 +8285,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'} @@ -8259,7 +8298,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qs@6.13.0: @@ -9492,6 +9530,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'} @@ -13256,15 +13297,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': {} @@ -13339,6 +13389,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 @@ -14694,6 +14747,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 @@ -16553,6 +16610,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 @@ -16669,6 +16730,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: {} @@ -16851,6 +16921,8 @@ snapshots: mdn-data@2.12.2: {} + mdurl@2.0.0: {} + media-typer@0.3.0: {} media-typer@1.1.0: {} @@ -17856,6 +17928,8 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + punycode.js@2.3.1: {} + punycode@2.3.1: {} q@1.5.1: {} @@ -19283,6 +19357,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