From 80cd81255eba85fb6c6f5c9ae2edc8fbf31488ef Mon Sep 17 00:00:00 2001 From: Adam DeHaven Date: Tue, 29 Sep 2026 13:36:14 -0400 Subject: [PATCH 1/4] fix(parse): preserve SVG and MathML element name casing Keep the author's case for attribute names inside SVG and MathML, and apply the HTML standard's case adjustment to their element names. Names outside foreign content stay lowercase, so viewBox and linearGradient now reach framework renderers intact. Inline HTML fragments are parsed one tag at a time, so the token processor passes the SVG/MathML context to each fragment. With that context, self-closed foreign elements such as are void, and closing tags with hyphens match their opener. Keep the first of case-variant duplicate attributes, as browsers do. Drop outerHTML from rendered attributes and reject it in the security plugin, because Vue sets it as a DOM property and a re-render then injects markup. Fixes #467 --- docs/content/4.plugins/1.built-in/security.md | 3 +- packages/comark-vue/test/sink-props.test.ts | 11 + packages/comark/SPEC/HTML/svg-block.md | 54 +++ packages/comark/SPEC/HTML/svg.md | 54 +++ .../comark/src/internal/parse/html/index.ts | 104 +++++- .../src/internal/parse/token-processor.ts | 32 +- .../comark/src/internal/props-validation.ts | 19 +- .../src/internal/stringify/attributes.ts | 4 +- packages/comark/test/props-validation.test.ts | 6 + .../comark/test/resolve-attributes.test.ts | 15 + packages/comark/test/svg-case.test.ts | 337 ++++++++++++++++++ 11 files changed, 608 insertions(+), 31 deletions(-) create mode 100644 packages/comark/SPEC/HTML/svg-block.md create mode 100644 packages/comark/SPEC/HTML/svg.md create mode 100644 packages/comark/test/svg-case.test.ts diff --git a/docs/content/4.plugins/1.built-in/security.md b/docs/content/4.plugins/1.built-in/security.md index 1de68551..d630c3b5 100644 --- a/docs/content/4.plugins/1.built-in/security.md +++ b/docs/content/4.plugins/1.built-in/security.md @@ -97,11 +97,12 @@ Attributes that can be abused regardless of value are always stripped: | `srcdoc` | Can contain arbitrary HTML | | `formaction` | Can redirect form submissions | | `innerHTML` | Injects raw HTML through framework renderers | +| `outerHTML` | Injects raw HTML through framework renderers | | `dangerouslySetInnerHTML` | Injects raw HTML through framework renderers | | `textContent` | Overwrites an element's children | ::note -Framework renderers (Vue, React, Svelte, Angular) never forward `innerHTML`, `dangerouslySetInnerHTML`, or `textContent` from document attributes, even without this plugin. Raw HTML has its own explicit path through the default `html` plugin. +Framework renderers (Vue, React, Svelte, Angular) never forward `innerHTML`, `outerHTML`, `dangerouslySetInnerHTML`, or `textContent` from document attributes, even without this plugin. Raw HTML has its own explicit path through the default `html` plugin. :: ### Protocol blocking diff --git a/packages/comark-vue/test/sink-props.test.ts b/packages/comark-vue/test/sink-props.test.ts index 68860153..7e89333b 100644 --- a/packages/comark-vue/test/sink-props.test.ts +++ b/packages/comark-vue/test/sink-props.test.ts @@ -27,4 +27,15 @@ describe('HTML sink props', () => { expect(html).toContain('safe') expect(html).not.toContain('overlay') }) + + it('never forwards markdown-authored outerHTML to h()', async () => { + // On the client, Vue sets outerHTML as a DOM property on HTML elements. + // A later patch then parses the value as markup. + const document = await parseMarkdown( + '[x]{outerHTML=""}\n\n
' + ) + const html = await renderDocument(document) + expect(html).not.toContain('onerror') + expect(html.toLowerCase()).not.toContain('outerhtml') + }) }) diff --git a/packages/comark/SPEC/HTML/svg-block.md b/packages/comark/SPEC/HTML/svg-block.md new file mode 100644 index 00000000..0cf864e2 --- /dev/null +++ b/packages/comark/SPEC/HTML/svg-block.md @@ -0,0 +1,54 @@ +## Input + +```md + + + +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "svg", + { + "$": { + "html": 1, + "block": 1 + }, + "viewBox": "0 0 10 10" + }, + [ + "linearGradient", + { + "$": { + "html": 1, + "block": 1 + }, + "id": "g" + } + ] + ] + ] +} +``` + +## HTML + +```html + + + +``` + +## Markdown + +```md + + + +``` diff --git a/packages/comark/SPEC/HTML/svg.md b/packages/comark/SPEC/HTML/svg.md new file mode 100644 index 00000000..c4459410 --- /dev/null +++ b/packages/comark/SPEC/HTML/svg.md @@ -0,0 +1,54 @@ +## Input + +```md + +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "p", + {}, + [ + "svg", + { + "$": { + "html": 1, + "block": 0 + }, + "viewBox": "0 0 10 10" + }, + [ + "linearGradient", + { + "$": { + "html": 1, + "block": 0 + }, + "id": "g" + } + ] + ] + ] + ] +} +``` + +## HTML + +```html +

+ +

+``` + +## Markdown + +```md + +``` diff --git a/packages/comark/src/internal/parse/html/index.ts b/packages/comark/src/internal/parse/html/index.ts index a69864fe..eb546756 100644 --- a/packages/comark/src/internal/parse/html/index.ts +++ b/packages/comark/src/internal/parse/html/index.ts @@ -18,19 +18,51 @@ export const VOID_ELEMENTS = new Set([ 'wbr', ]) -function attribsToComarkAttrs(attribs: Record, isInline: boolean = false): Record { +/** + * Elements that start HTML foreign content (SVG, MathML). Inside them, the + * parser adjusts element names to their SVG/MathML case and attribute names + * keep the author's case (#467). This is the foreign-content boundary from + * the HTML standard, not an element map. + */ +const FOREIGN_CONTENT_TAGS = new Set(['svg', 'math']) + +/** Whether `tag` starts a foreign-content (case-sensitive) subtree. */ +export function isForeignContentTag(tag: string): boolean { + return FOREIGN_CONTENT_TAGS.has(tag.toLowerCase()) +} + +/** + * Convert parser attributes to Comark attributes. + * + * @param attribs - Attributes from the parser, with the author's name case + * @param isInline - Whether the element comes from inline HTML + * @param preserveCase - Keep the author's name case (foreign content) + */ +function attribsToComarkAttrs( + attribs: Record, + isInline: boolean = false, + preserveCase: boolean = false +): Record { const attrs: Record = { $: { html: 1, block: isInline ? 0 : 1, }, } + // The HTML tokenizer compares attribute names without case and keeps the + // first one. The parser gives raw names, so `href` and `HREF` arrive as two + // keys. Skip the later key to match browsers. + const seen = new Set() for (const key in attribs) { + const lower = key.toLowerCase() + if (seen.has(lower)) continue + seen.add(lower) const value = attribs[key] + const name = preserveCase ? key : lower if (value === '') { - attrs[`:${key}`] = 'true' + attrs[`:${name}`] = 'true' } else { - attrs[key] = value + attrs[name] = value } } return attrs @@ -46,33 +78,70 @@ interface HtmlTagInfo { /** * Parse a single inline HTML tag fragment (opening, closing, or void). * Returns null if the content is not a recognisable HTML tag. + * + * The parser reads each fragment alone, so it cannot see an SVG/MathML + * ancestor. `inForeignContent` gives that context: + * + * - The fragment is parsed inside a synthetic `` wrapper. The parser + * then adjusts the element name to its SVG/MathML case (LINEARGRADIENT + * becomes linearGradient) and makes unknown names lowercase. No element + * map is necessary. + * - Attribute names keep the author's case, because they are case-sensitive + * in foreign content (#467). + * + * In foreign content, a fragment is void when the parser closes it while it + * reads the fragment. This covers self-closed foreign elements (``) + * and a self-closed `` or ``. Outside foreign content, only + * `VOID_ELEMENTS` are void: HTML ignores `/>`, so `
` stays open. */ -export function parseInlineHtmlTag(html: string): HtmlTagInfo | null { +export function parseInlineHtmlTag(html: string, inForeignContent: boolean = false): HtmlTagInfo | null { const trimmed = html.trim() if (!trimmed.startsWith('<')) return null - // Fast path: closing tag - const closeMatch = trimmed.match(/^<\/([a-z][a-z0-9]*)\s*>/i) + // Fast path: closing tag. The name grammar is the same as the tokenizer's + // close_tag rule, which permits hyphens (custom elements, annotation-xml). + // The name is only for case-insensitive matching against the opener. + const closeMatch = trimmed.match(/^<\/([a-z][a-z0-9-]*)\s*>/i) if (closeMatch) { return { tag: closeMatch[1].toLowerCase(), attrs: {}, isVoid: false, isClose: true } } let info: HtmlTagInfo | null = null + let reading = true + // With a wrapper, the first open tag is the wrapper itself. + let wrapperOpenTags = inForeignContent ? 1 : 0 + const parser = new Parser( { onopentag(name, attribs) { + if (wrapperOpenTags > 0) { + wrapperOpenTags-- + return + } + if (info) return + const lower = name.toLowerCase() info = { tag: name, - attrs: attribsToComarkAttrs(attribs, true), - isVoid: VOID_ELEMENTS.has(name), + attrs: attribsToComarkAttrs(attribs, true, inForeignContent || FOREIGN_CONTENT_TAGS.has(lower)), + isVoid: VOID_ELEMENTS.has(lower), isClose: false, } }, + onclosetag(name) { + // In foreign content, a close during write() means a self-closed + // element. The parser closes elements that stay open later, in end(). + // HTML elements outside foreign content keep the VOID_ELEMENTS rule. + const foreign = inForeignContent || (info !== null && FOREIGN_CONTENT_TAGS.has(info.tag.toLowerCase())) + if (reading && info && foreign && name.toLowerCase() === info.tag.toLowerCase()) { + info.isVoid = true + } + }, }, - { decodeEntities: false } + { decodeEntities: false, lowerCaseAttributeNames: false } ) - parser.write(trimmed) + parser.write(inForeignContent ? `${trimmed}` : trimmed) + reading = false parser.end() return info } @@ -80,15 +149,22 @@ export function parseInlineHtmlTag(html: string): HtmlTagInfo | null { /** * Parse a full HTML string into Nodes using htmlparser2. * Handles nested elements, text, void elements, and comments. + * + * The parser makes HTML element names lowercase and adjusts SVG/MathML + * element names to their case. Attribute names arrive raw + * (`lowerCaseAttributeNames: false`). This function makes them lowercase, + * except inside foreign content, where names are case-sensitive (#467). */ export function htmlToNodes(html: string): Node[] { const root: Node[] = [] - const stack: { tag: string; attrs: Record; children: Node[] }[] = [] + const stack: { tag: string; attrs: Record; children: Node[]; foreign: boolean }[] = [] const parser = new Parser( { onopentag(name, attribs) { - const attrs = attribsToComarkAttrs(attribs) + const parentForeign = stack.length > 0 && stack[stack.length - 1].foreign + const foreign = parentForeign || FOREIGN_CONTENT_TAGS.has(name) + const attrs = attribsToComarkAttrs(attribs, false, foreign) if (VOID_ELEMENTS.has(name)) { const node = [name, attrs] as Node if (stack.length > 0) { @@ -98,7 +174,7 @@ export function htmlToNodes(html: string): Node[] { } return } - stack.push({ tag: name, attrs, children: [] }) + stack.push({ tag: name, attrs, children: [], foreign }) }, ontext(text) { @@ -145,7 +221,7 @@ export function htmlToNodes(html: string): Node[] { } }, }, - { decodeEntities: true } + { decodeEntities: true, lowerCaseAttributeNames: false } ) parser.write(html.trim()) diff --git a/packages/comark/src/internal/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts index e8c3e001..2dd3c6c7 100644 --- a/packages/comark/src/internal/parse/token-processor.ts +++ b/packages/comark/src/internal/parse/token-processor.ts @@ -1,6 +1,6 @@ import type { ElementNode, Node } from 'comark' import { textContent } from 'comark/utils' -import { htmlToNodes, parseInlineHtmlTag } from './html/index.ts' +import { htmlToNodes, isForeignContentTag, parseInlineHtmlTag } from './html/index.ts' // `::tag` components that should fold into a single same-tagged child. const WRAPPER_TAGS = new Set(['ul', 'ol', 'table', 'blockquote', 'pre']) @@ -704,11 +704,23 @@ export function processInlineTokens(tokens: any[], inHeading: boolean = false): // the unrecognized-tag fallback. Mirrors markdown-it's default maxNesting. const MAX_INLINE_HTML_DEPTH = 100 +/** + * Process a single inline token into a node. + * + * @param tokens - The inline token stream + * @param startIndex - Index of the token to process + * @param inHeading - Whether the tokens belong to a heading + * @param htmlDepth - Current inline HTML nesting depth (recursion guard) + * @param inForeignContent - Whether the position is inside an SVG/MathML + * subtree. There, element names get their SVG/MathML case and attribute + * names keep the author's case (#467) + */ function processInlineToken( tokens: any[], startIndex: number, inHeading: boolean = false, - htmlDepth: number = 0 + htmlDepth: number = 0, + inForeignContent: boolean = false ): { node: Node | string | null; nextIndex: number } { const token = tokens[startIndex] @@ -724,7 +736,7 @@ function processInlineToken( // Handle html_inline tokens using htmlparser2 if (token.type === 'html_inline') { const content = token.content || '' - const tagInfo = parseInlineHtmlTag(content) + const tagInfo = parseInlineHtmlTag(content, inForeignContent) if (!tagInfo) { // Not a recognisable tag — return as raw text @@ -746,6 +758,10 @@ function processInlineToken( return { node: content || null, nextIndex: startIndex + 1 } } + // SVG/MathML subtrees are case-sensitive (#467). The children of this + // tag are parsed with that context. + const childInForeignContent = inForeignContent || isForeignContentTag(tagInfo.tag) + // Non-void opening tag — look ahead for the matching closing tag const children: Node[] = [] let j = startIndex + 1 @@ -753,13 +769,13 @@ function processInlineToken( while (j < tokens.length) { const nextToken = tokens[j] if (nextToken.type === 'html_inline') { - const nextInfo = parseInlineHtmlTag(nextToken.content || '') - if (nextInfo?.isClose && nextInfo.tag === tagInfo.tag) { + const nextInfo = parseInlineHtmlTag(nextToken.content || '', childInForeignContent) + if (nextInfo?.isClose && nextInfo.tag.toLowerCase() === tagInfo.tag.toLowerCase()) { j++ // consume the closing tag break } } - const result = processInlineToken(tokens, j, inHeading, htmlDepth + 1) + const result = processInlineToken(tokens, j, inHeading, htmlDepth + 1, childInForeignContent) j = result.nextIndex if (result.node) { children.push(result.node as Node) @@ -794,7 +810,7 @@ function processInlineToken( } // Process other tokens - const result = processInlineToken(tokens, i, inHeading, htmlDepth) + const result = processInlineToken(tokens, i, inHeading, htmlDepth, inForeignContent) i = result.nextIndex if (result.node) { nodes.push(result.node as Node) @@ -855,7 +871,7 @@ function processInlineToken( } // Process child token - const result = processInlineToken(tokens, i, inHeading, htmlDepth) + const result = processInlineToken(tokens, i, inHeading, htmlDepth, inForeignContent) i = result.nextIndex if (result.node) { children.push(result.node as Node) diff --git a/packages/comark/src/internal/props-validation.ts b/packages/comark/src/internal/props-validation.ts index e83ec5c5..b5f3d64c 100644 --- a/packages/comark/src/internal/props-validation.ts +++ b/packages/comark/src/internal/props-validation.ts @@ -7,12 +7,19 @@ export const REJECTED_PROP = Symbol('comark:rejected-prop') export const unsafeTags = ['object'] -// `innerHTML` / `dangerouslySetInnerHTML` / `textContent` are DOM sinks that -// turn a string prop into raw markup or overwrite an element's children. -// Framework renderers receive resolved props verbatim, so markdown-authored -// values would otherwise bypass tag filtering (raw HTML has its own explicit -// path via the `html` plugin and does not need these). -export const unsafeAttributes = ['srcdoc', 'formaction', 'innerhtml', 'dangerouslysetinnerhtml', 'textcontent'] +// `innerHTML` / `outerHTML` / `dangerouslySetInnerHTML` / `textContent` are DOM +// sinks that turn a string prop into raw markup or overwrite an element's +// children. Framework renderers receive resolved props verbatim, so +// markdown-authored values would otherwise bypass tag filtering (raw HTML has +// its own explicit path via the `html` plugin and does not need these). +export const unsafeAttributes = [ + 'srcdoc', + 'formaction', + 'innerhtml', + 'outerhtml', + 'dangerouslysetinnerhtml', + 'textcontent', +] export const unsafeLinkPrefix = [ 'javascript:', diff --git a/packages/comark/src/internal/stringify/attributes.ts b/packages/comark/src/internal/stringify/attributes.ts index 9c7511b6..1093582d 100644 --- a/packages/comark/src/internal/stringify/attributes.ts +++ b/packages/comark/src/internal/stringify/attributes.ts @@ -21,11 +21,11 @@ export interface ResolveAttributesOptions { } // DOM sinks that turn a string/object prop into raw markup (`innerHTML`, -// `dangerouslySetInnerHTML`) or overwrite an element's children +// `outerHTML`, `dangerouslySetInnerHTML`) or overwrite an element's children // (`textContent`). Framework renderers hand resolved attributes to // `h()`/`createElement`/spreads verbatim, so these keys are never forwarded // from document attributes — raw HTML has its own explicit path. -const HTML_SINK_PROPS = new Set(['innerhtml', 'dangerouslysetinnerhtml', 'textcontent']) +const HTML_SINK_PROPS = new Set(['innerhtml', 'outerhtml', 'dangerouslysetinnerhtml', 'textcontent']) /** * Resolve `:prefixed` attributes against the render context. diff --git a/packages/comark/test/props-validation.test.ts b/packages/comark/test/props-validation.test.ts index 6c067de3..4deb6877 100644 --- a/packages/comark/test/props-validation.test.ts +++ b/packages/comark/test/props-validation.test.ts @@ -42,6 +42,12 @@ describe('validateProp', () => { it('blocks textContent', () => { expect(validateProp('textContent', 'overlay')).toBe(REJECTED_PROP) }) + + it('blocks outerHTML (any case)', () => { + expect(validateProp('outerHTML', '')).toBe(REJECTED_PROP) + expect(validateProp('OUTERHTML', '')).toBe(REJECTED_PROP) + expect(validateProp(':outerHTML', '""')).toBe(REJECTED_PROP) + }) }) describe('href safety', () => { diff --git a/packages/comark/test/resolve-attributes.test.ts b/packages/comark/test/resolve-attributes.test.ts index bab8b0b2..6a9621cd 100644 --- a/packages/comark/test/resolve-attributes.test.ts +++ b/packages/comark/test/resolve-attributes.test.ts @@ -128,6 +128,21 @@ describe('HTML sink props', () => { ) expect(result).toEqual({ id: 'keep' }) }) + + it('drops outerHTML with any casing and with the :binding prefix', () => { + // Vue sets `outerHTML` as a DOM property on HTML elements, which parses + // the value as markup once the element is attached. + const result = resolveAttributes( + { + outerHTML: '', + ':OuterHtml': '""', + title: 'safe', + }, + makeRenderData(), + { parseJson: true } + ) + expect(result).toEqual({ title: 'safe' }) + }) }) describe('unsafe URL bindings (render-time hard floor)', () => { diff --git a/packages/comark/test/svg-case.test.ts b/packages/comark/test/svg-case.test.ts new file mode 100644 index 00000000..95051aa2 --- /dev/null +++ b/packages/comark/test/svg-case.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from 'vitest' +import { createMarkdownParser, parseMarkdown } from '../src/parse' +import security from '../src/plugins/security' +import { renderMarkdown, resolveAttributes } from '../src/render' +import type { ElementNode } from '../src/types' + +/** + * SVG/MathML foreign content is case-sensitive. Element names get their + * SVG/MathML case from the HTML standard's adjustment rules, and attribute + * names keep the author's case. HTML names outside stay lowercase (#467). + */ + +describe('SVG case preservation', () => { + it('preserves SVG element and attribute names in inline HTML', async () => { + const result = await parseMarkdown('') + expect(result.nodes).toEqual([ + [ + 'p', + {}, + [ + 'svg', + { $: { html: 1, block: 0 }, viewBox: '0 0 10 10' }, + ['linearGradient', { $: { html: 1, block: 0 }, id: 'g' }], + ], + ], + ]) + }) + + it('preserves SVG element and attribute names in HTML blocks', async () => { + const result = await parseMarkdown('\n \n') + expect(result.nodes).toEqual([ + [ + 'svg', + { $: { html: 1, block: 1 }, viewBox: '0 0 10 10' }, + ['linearGradient', { $: { html: 1, block: 1 }, id: 'g' }], + ], + ]) + }) + + it('adjusts wrongly-cased SVG tag names in inline HTML via the HTML standard rules', async () => { + // Authors write the element name wrong; the foreign-content adjustment + // tables (applied by htmlparser2 inside a synthetic wrapper) fix it. + const result = await parseMarkdown('') + expect(result.nodes).toEqual([ + ['p', {}, ['svg', { $: { html: 1, block: 0 } }, ['linearGradient', { $: { html: 1, block: 0 }, X1: '0' }]]], + ]) + }) + + it('adjusts wrongly-cased SVG tag names in HTML blocks', async () => { + const result = await parseMarkdown('\n\n\n') + expect(result.nodes).toEqual([ + ['svg', { $: { html: 1, block: 1 } }, ['linearGradient', { $: { html: 1, block: 1 }, X1: '0' }]], + ]) + }) + + it('keeps SVG siblings flat when children are self-closed inline', async () => { + // Regression: used to open a subtree that swallowed . + const result = await parseMarkdown('') + expect(result.nodes).toEqual([ + [ + 'p', + {}, + [ + 'svg', + { $: { html: 1, block: 0 }, viewBox: '0 0 1 1' }, + ['path', { $: { html: 1, block: 0 }, d: 'M0 0' }], + ['circle', { $: { html: 1, block: 0 }, r: '1' }], + ], + ], + ]) + }) + + it('keeps SVG siblings flat when children are self-closed in blocks', async () => { + const result = await parseMarkdown('\n \n \n') + expect(result.nodes).toEqual([ + [ + 'svg', + { $: { html: 1, block: 1 } }, + ['path', { $: { html: 1, block: 1 }, d: 'M0 0' }], + ['circle', { $: { html: 1, block: 1 }, r: '1' }], + ], + ]) + }) + + it('treats a self-closed root as void and keeps following text outside', async () => { + const result = await parseMarkdown('a b') + expect(result.nodes).toEqual([['p', {}, 'a ', ['svg', { $: { html: 1, block: 0 }, viewBox: '0 0 1 1' }], ' b']]) + }) + + it('treats a self-closed root as void and keeps following text outside', async () => { + const result = await parseMarkdown(' after') + expect(result.nodes).toEqual([['p', {}, ['math', { $: { html: 1, block: 0 } }], ' after']]) + }) + + it('does not change void handling for HTML elements outside SVG', async () => { + // htmlparser2 closes some legacy HTML elements at once (keygen, frame). + // Inline HTML outside foreign content keeps the VOID_ELEMENTS rule. + const result = await parseMarkdown('a b c') + expect(result.nodes).toEqual([['p', {}, 'a ', ['keygen', { $: { html: 1, block: 0 } }, 'b'], ' c']]) + }) + + it('still ignores the self-closing flag for HTML elements', async () => { + // Per HTML,
does not close the element; children run to
. + const result = await parseMarkdown('
x
') + expect(result.nodes).toEqual([['div', { $: { html: 1, block: 1 } }, 'x']]) + }) + + it('still ignores the self-closing flag for inline HTML elements', async () => { + const result = await parseMarkdown('a b c') + expect(result.nodes).toEqual([['p', {}, 'a ', ['span', { $: { html: 1, block: 0 } }, 'b'], ' c']]) + }) + + it('lowercases HTML element and attribute names outside SVG (inline)', async () => { + const result = await parseMarkdown('World') + expect(result.nodes).toEqual([ + ['p', {}, ['hello', { $: { html: 1, block: 0 }, class: 'x', 'data-foo': '1' }, 'World']], + ]) + }) + + it('lowercases HTML element and attribute names outside SVG (block)', async () => { + const result = await parseMarkdown('\nWorld\n') + expect(result.nodes).toEqual([['hello', { $: { html: 1, block: 1 }, class: 'x' }, 'World']]) + }) + + it('lowercases an uppercase SVG opener but keeps foreign-content casing', async () => { + // is an HTML-parsed start tag, so it is lowercased. ClipPath is + // wrongly cased and normalized to the SVG name by the adjustment rules. + const result = await parseMarkdown('') + expect(result.nodes).toEqual([ + ['p', {}, ['svg', { $: { html: 1, block: 0 } }, ['clipPath', { $: { html: 1, block: 0 }, ID: 'c' }]]], + ]) + }) + + it('preserves casing inside MathML content and adjusts wrongly-cased names', async () => { + const result = await parseMarkdown('') + expect(result.nodes).toEqual([ + ['p', {}, ['math', { $: { html: 1, block: 0 }, displayStyle: 'true' }, ['mrow', { $: { html: 1, block: 0 } }]]], + ]) + }) + + it('keeps siblings after the closing tag of a camelCase SVG element (inline)', async () => { + // The close fast path lowercases ``. The opener keeps + // `linearGradient`. The match must ignore case, or becomes a + // child of . The sibling is what makes a mismatch + // visible: an unmatched close is skipped as an orphan. + const result = await parseMarkdown(' after') + expect(result.nodes).toEqual([ + [ + 'p', + {}, + [ + 'svg', + { $: { html: 1, block: 0 } }, + ['linearGradient', { $: { html: 1, block: 0 }, id: 'g' }], + ['circle', { $: { html: 1, block: 0 }, r: '1' }], + ], + ' after', + ], + ]) + }) + + it('matches closing tags case-insensitively (inline)', async () => { + const result = await parseMarkdown( + ' after' + ) + expect(result.nodes).toEqual([ + [ + 'p', + {}, + [ + 'svg', + { $: { html: 1, block: 0 }, viewBox: '0 0 1 1' }, + ['linearGradient', { $: { html: 1, block: 0 }, id: 'g' }], + ['circle', { $: { html: 1, block: 0 }, r: '1' }], + ], + ' after', + ], + ]) + }) + + it('matches closing tags case-insensitively (block)', async () => { + const result = await parseMarkdown('\n\n\n\n') + expect(result.nodes).toEqual([ + [ + 'svg', + { $: { html: 1, block: 1 } }, + ['linearGradient', { $: { html: 1, block: 1 }, id: 'g' }], + ['circle', { $: { html: 1, block: 1 }, r: '1' }], + ], + ]) + }) + + it('keeps author attribute casing for HTML inside foreignObject (documented trade-off)', async () => { + // The parser does not model foreignObject as an HTML integration point. + // The whole SVG subtree keeps the author's attribute case. The DOM + // ignores attribute case on HTML elements, and the renderer floor drops + // DOM property sinks (see the outerHTML test). + const result = await parseMarkdown('\n
hi
\n
') + expect(result.nodes).toEqual([ + [ + 'svg', + { $: { html: 1, block: 1 } }, + ['foreignObject', { $: { html: 1, block: 1 } }, ['div', { $: { html: 1, block: 1 }, CLASS: 'x' }, 'hi']], + ], + ]) + }) + + it('swallows the rest of the paragraph into an unclosed inline SVG', async () => { + // An unclosed has no end tag to stop the lookahead, so the + // remaining inline tokens become its children. No crash, casing kept. + const result = await parseMarkdown('text more text') + expect(result.nodes).toEqual([ + ['p', {}, 'text ', ['svg', { $: { html: 1, block: 0 }, viewBox: '0 0 1 1' }, ' more text']], + ]) + }) + + it('handles SVG with self-closing children inside headings', async () => { + const result = await parseMarkdown('# T ') + expect(result.nodes).toEqual([ + [ + 'h1', + { id: 't' }, + 'T ', + ['svg', { $: { html: 1, block: 0 }, viewBox: '0 0 1 1' }, ['path', { $: { html: 1, block: 0 }, d: 'M0 0' }]], + ], + ]) + }) + + it('keeps markdown inline parsing inside SVG text children', async () => { + const result = await parseMarkdown('Hi **bold**') + expect(result.nodes).toEqual([ + [ + 'p', + {}, + ['svg', { $: { html: 1, block: 0 } }, ['text', { $: { html: 1, block: 0 } }, 'Hi ', ['strong', {}, 'bold']]], + ], + ]) + }) + + it('preserves casing when parsing with streaming enabled', async () => { + // The incomplete opener is dropped by auto-close (its + // own documented behavior for trailing openers); the completed + // keeps its casing and does not crash. + const result = await createMarkdownParser()(' { + // In `href=/x/>` the slash belongs to the unquoted value. The tag is not + // self-closed, so `link` stays inside the element. + const result = await parseMarkdown('link') + expect(result.nodes).toEqual([ + ['p', {}, ['svg', { $: { html: 1, block: 0 } }, ['a', { $: { html: 1, block: 0 }, href: '/x/' }, 'link']]], + ]) + }) + + it('keeps the first of case-variant duplicate attributes on HTML elements', async () => { + // Browsers keep the first `href`. If the last one won, a later `HREF` + // could replace a value that a sanitizer already checked. + const result = await parseMarkdown('x') + expect(result.nodes).toEqual([['p', {}, ['a', { $: { html: 1, block: 0 }, href: '/safe' }, 'x']]]) + }) + + it('keeps the first of case-variant duplicate attributes in HTML blocks', async () => { + const result = await parseMarkdown('
\nx\n
') + expect(result.nodes).toEqual([['div', { $: { html: 1, block: 1 }, title: 'first' }, 'x']]) + }) + + it('keeps the first of case-variant duplicate attributes inside SVG', async () => { + const result = await parseMarkdown('') + expect(result.nodes).toEqual([['p', {}, ['svg', { $: { html: 1, block: 0 }, viewBox: '0 0 1 1' }]]]) + }) + + it('matches hyphenated closing tags of custom elements', async () => { + const result = await parseMarkdown('x after') + expect(result.nodes).toEqual([['p', {}, ['my-el', { $: { html: 1, block: 0 } }, 'x'], ' after']]) + }) + + it('matches hyphenated closing tags in MathML (annotation-xml)', async () => { + const result = await parseMarkdown('y z') + expect(result.nodes).toEqual([ + [ + 'p', + {}, + [ + 'math', + { $: { html: 1, block: 0 } }, + ['annotation-xml', { $: { html: 1, block: 0 }, encoding: 'text/html' }, 'y'], + ' z', + ], + ], + ]) + }) + + it('does not forward DOM property sinks from foreign content to renderers', async () => { + // SVG subtrees keep attribute case, so `outerHTML` no longer becomes an + // inert `outerhtml`. Vue sets `outerHTML` as a DOM property on HTML + // elements inside foreignObject, so the renderer floor must drop it. + const result = await parseMarkdown( + '
' + ) + const p = result.nodes[0] as ElementNode + const svg = p[2] as ElementNode + const foreignObject = svg[2] as ElementNode + const div = foreignObject[2] as ElementNode + expect(div[0]).toBe('div') + const renderData = { frontmatter: {}, meta: {}, data: {}, props: {} } + expect(resolveAttributes(div[1], renderData, { parseJson: true })).toEqual({ title: 't' }) + }) + + it('drops DOM property sinks inside SVG with the security plugin', async () => { + const result = await parseMarkdown('', { + plugins: [security()], + }) + expect(result.nodes).toEqual([['p', {}, ['svg', { $: { html: 1, block: 0 }, viewBox: '0 0 1 1' }]]]) + }) + + it('round-trips SVG casing through renderMarkdown (inline)', async () => { + const markdown = '' + const document = await parseMarkdown(markdown) + const output = await renderMarkdown(document) + // Assert the output text: a fixed-point check alone also passes when + // both parses lowercase the names. + expect(output.trim()).toBe(markdown) + const reparsed = await parseMarkdown(output) + expect(reparsed.nodes).toEqual(document.nodes) + }) + + it('round-trips SVG casing through renderMarkdown (block)', async () => { + const markdown = '\n\n' + const document = await parseMarkdown(markdown) + const output = await renderMarkdown(document) + expect(output.trim()).toBe(markdown) + const reparsed = await parseMarkdown(output) + expect(reparsed.nodes).toEqual(document.nodes) + }) +}) From 37585ac443d4fbb4901415a69ea3b3671b5ba709 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Thu, 15 Oct 2026 11:06:08 +0200 Subject: [PATCH 2/4] fix(parse): preserve SVG/MathML attribute case and scope inline HTML to markdown containers --- AGENTS.md | 2 +- docs/content/4.plugins/0.defaults/html.md | 2 + packages/comark/SPEC/HTML/svg-block.md | 2 +- packages/comark/SPEC/HTML/svg.md | 7 +- packages/comark/src/internal/parse/tree.ts | 59 ++- packages/comark/src/internal/parse/utils.ts | 25 +- packages/comark/test/svg-case.test.ts | 427 ++++++++------------ 7 files changed, 244 insertions(+), 280 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index de5caaeb..023f5d56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -762,7 +762,7 @@ Contract: `packages/comark/SPEC/HTML/README.md`. HTML nodes carry `$: { html: 1, block: 0 | 1 }`. `block` means the tag outlived the paragraph that opened it, not that the tag is a block element. `html({ markdown: false })` keeps closed-block text literal; a blank line still nests real markdown under -the open tag. `