From f7ae4f5c2f49fcb923aeaf4e06ee59c0677e8855 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 23 Oct 2026 15:36:49 +0200 Subject: [PATCH 1/6] feat(comark)!: drop entities dependency BREAKING CHANGE: `decodeHTML` is no longer exported from `comark/utils`, and `textContent()` no longer accepts the `decodeUnicodeEntities` option. Text nodes are already entity-decoded by the parser; import `decodeHTML` from `entities` directly if you still need it. --- packages/comark/package.json | 1 - packages/comark/src/internal/parse/utils.ts | 18 ++++++++++++++++-- packages/comark/src/utils/index.ts | 14 +++----------- packages/comark/test/html-escape.test.ts | 7 +++++++ pnpm-lock.yaml | 6 ------ pnpm-workspace.yaml | 1 - 6 files changed, 26 insertions(+), 21 deletions(-) diff --git a/packages/comark/package.json b/packages/comark/package.json index 315c0bbcc..ba2df2105 100644 --- a/packages/comark/package.json +++ b/packages/comark/package.json @@ -68,7 +68,6 @@ "release:dry": "release-it --dry-run" }, "dependencies": { - "entities": "catalog:", "js-yaml": "catalog:", "markdown-exit": "catalog:" }, diff --git a/packages/comark/src/internal/parse/utils.ts b/packages/comark/src/internal/parse/utils.ts index 8bcc6a825..1fbd73dfc 100644 --- a/packages/comark/src/internal/parse/utils.ts +++ b/packages/comark/src/internal/parse/utils.ts @@ -1,5 +1,6 @@ import type { Node, ElementNode, ElementNodeAttributes, MarkdownDocument } from 'comark' -import { decodeHTML, isHtmlVoidElement, TREE_WALK_MAX_DEPTH } from 'comark/utils' +import { isHtmlVoidElement, TREE_WALK_MAX_DEPTH } from 'comark/utils' +import { MarkdownExit } from 'markdown-exit' export type ParsedHtmlTag = | { kind: 'open'; tag: string; attrs: Record; selfClosing: boolean } @@ -181,6 +182,19 @@ export function parseHtmlInline(content: string): ParsedHtmlTag { return { kind: 'other', content: trimmed } } +let unescapeAll: ((str: string) => string) | undefined + +/** + * Decode HTML entities in a raw attribute value with markdown-exit's entity table. + * `unescapeAll` also strips markdown backslash escapes, so backslashes are doubled + * first to keep them literal, as HTML attributes have no backslash escapes. + */ +function decodeAttributeValue(value: string): string { + if (!value.includes('&')) return value + unescapeAll ??= new MarkdownExit().utils.unescapeAll + return unescapeAll(value.replaceAll('\\', '\\\\')) +} + /** * Parse HTML attribute string into a key/value map. * Supports `key="value"`, `key='value'`, bare `key=value`, and boolean attributes. @@ -195,7 +209,7 @@ export function parseHtmlAttributes(attrsStr: string): Record { while ((match = re.exec(attrsStr)) !== null) { const key = match[1].toLowerCase() const value = match[2] ?? match[3] ?? match[4] - attrs[key] = value === undefined ? true : decodeHTML(value) + attrs[key] = value === undefined ? true : decodeAttributeValue(value) } return attrs diff --git a/packages/comark/src/utils/index.ts b/packages/comark/src/utils/index.ts index 9c2484baf..5d0087f59 100644 --- a/packages/comark/src/utils/index.ts +++ b/packages/comark/src/utils/index.ts @@ -1,10 +1,7 @@ // #region Tree Utils -import { decodeHTML } from 'entities' import type { Node, MarkdownDocument } from 'comark' -export { decodeHTML } from 'entities' - type VisitResult = Node | false | undefined | void /** @@ -18,21 +15,16 @@ export function isMarkdownDocument(value: unknown): value is MarkdownDocument { * Get the text content of a Markdown AST node * * @param node - The node - * @param options - The options - * @param options.decodeUnicodeEntities - Whether to decode Unicode entities * @returns The text content */ -export function textContent(node: Node, options: { decodeUnicodeEntities?: boolean } = {}): string { +export function textContent(node: Node): string { if (typeof node === 'string') { - if (options.decodeUnicodeEntities) { - return decodeHTML(node) - } - return node as string + return node } let out = '' const len = node.length for (let i = 2; i < len; i++) { - out += textContent(node[i] as Node, options) + out += textContent(node[i] as Node) } return out } diff --git a/packages/comark/test/html-escape.test.ts b/packages/comark/test/html-escape.test.ts index 81120661a..e1e01ddfd 100644 --- a/packages/comark/test/html-escape.test.ts +++ b/packages/comark/test/html-escape.test.ts @@ -39,6 +39,13 @@ describe('HTML attribute escaping', () => { expect(html).not.toContain('title="A "quote""') }) + it('keeps backslashes literal while decoding HTML attribute entities', async () => { + const tree = await parseMarkdown('hi') + const p = tree.nodes[0] as [string, Record, ...Node[]] + const span = (Array.isArray(p[2]) ? p[2] : p) as [string, Record, ...Node[]] + expect(span[1].title).toBe('C:\\*dir\\ & ©') + }) + it('escapes quotes in component attribute props', async () => { const html = await renderHtml(`:span[hi]{title='a" onmouseover=alert(1) x="b'}`) expect(html).toContain('title="a" onmouseover=alert(1) x="b"') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b3d64d40..26c106e6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -317,9 +317,6 @@ catalogs: beautiful-mermaid: specifier: ^1.1.3 version: 1.1.3 - entities: - specifier: ^8.1.0 - version: 8.1.0 github-slugger: specifier: ^2.0.0 version: 2.0.0 @@ -1419,9 +1416,6 @@ importers: beautiful-mermaid: specifier: 'catalog:' version: 1.1.3 - entities: - specifier: 'catalog:' - version: 8.1.0 js-yaml: specifier: 'catalog:' version: 5.4.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0c129a34d..39872d675 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -90,7 +90,6 @@ catalog: comark: ^0.8.1 # Core comark dependencies - entities: ^8.1.0 github-slugger: ^2.0.0 hast-util-to-string: ^3.0.1 js-yaml: ^5.4.2 From 6b51b4f75b74847b2299cbf987d69cc1fd35d271 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 23 Oct 2026 16:01:44 +0200 Subject: [PATCH 2/6] fix(comark): avoid String.replaceAll for ES2020 consumers --- packages/comark/src/internal/parse/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/comark/src/internal/parse/utils.ts b/packages/comark/src/internal/parse/utils.ts index 1fbd73dfc..ac1c5c11c 100644 --- a/packages/comark/src/internal/parse/utils.ts +++ b/packages/comark/src/internal/parse/utils.ts @@ -192,7 +192,7 @@ let unescapeAll: ((str: string) => string) | undefined function decodeAttributeValue(value: string): string { if (!value.includes('&')) return value unescapeAll ??= new MarkdownExit().utils.unescapeAll - return unescapeAll(value.replaceAll('\\', '\\\\')) + return unescapeAll(value.replace(/\\/g, '\\\\')) } /** From d2db0ed34b914912a34c3e022f002f7a365a03e7 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Mon, 26 Oct 2026 14:26:58 +0100 Subject: [PATCH 3/6] chore(bench): add HTML attributes parse benchmark The existing parse fixtures barely contain raw HTML, so changes to parseHtmlAttributes were not measured. --- benchmarks/fixtures.ts | 22 ++++++++++++++++++++++ benchmarks/html.bench.ts | 21 +++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 benchmarks/html.bench.ts diff --git a/benchmarks/fixtures.ts b/benchmarks/fixtures.ts index 3e4d5da4a..29b09343a 100644 --- a/benchmarks/fixtures.ts +++ b/benchmarks/fixtures.ts @@ -253,6 +253,28 @@ ${Array.from( That's it — let me know if you want a full example! ` +/** + * Raw HTML with many attributes, as found in docs written for GitHub/VitePress. + * Every tag goes through `parseHtmlAttributes`, half of the values carry + * entities so both the plain and the decoding path are measured. + */ +export const htmlAttributesMarkdown = `# Raw HTML + +${Array.from( + { length: 30 }, + (_, i) => ` + + +Inline Ctrl + R&R and docs ${i}. +` +).join('\n')} +` + /** Splits `markdown` into `count` growing prefixes, ending with the full text. */ export function toStreamChunks(markdown: string, count: number): string[] { const chunks: string[] = [] diff --git a/benchmarks/html.bench.ts b/benchmarks/html.bench.ts new file mode 100644 index 000000000..0025b3e7a --- /dev/null +++ b/benchmarks/html.bench.ts @@ -0,0 +1,21 @@ +import { bench, describe } from 'vitest' +import { createMarkdownParser } from 'comark' +import { htmlAttributesMarkdown } from './fixtures.ts' + +// Raw HTML attributes are decoded by `parseHtmlAttributes`; the generic parse +// fixtures barely contain HTML, so they cannot catch changes on that path. +const parse = createMarkdownParser() +const parseLiteralHtml = createMarkdownParser({ autoClose: false }) + +await parse(htmlAttributesMarkdown) +await parseLiteralHtml(htmlAttributesMarkdown) + +describe('parse html', () => { + bench('html attributes document', async () => { + await parse(htmlAttributesMarkdown) + }) + + bench('html attributes document without auto-close', async () => { + await parseLiteralHtml(htmlAttributesMarkdown) + }) +}) From b0b6d6016031c2eb59c28d8e40d318bebdedf99f Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Mon, 26 Oct 2026 14:27:02 +0100 Subject: [PATCH 4/6] fix(comark): decode HTML attributes with attribute semantics Use entities' decodeHTMLAttribute instead of markdown-exit's unescapeAll: it matches browser attribute parsing (legacy '&' is decoded, '?a=1©=2' stays literal), needs no backslash workaround, and is ~2.8x faster. entities is pinned to ^7.0.0 to dedupe with markdown-exit's copy. --- packages/comark/package.json | 1 + packages/comark/src/internal/parse/utils.ts | 14 +++++--------- packages/comark/test/html-escape.test.ts | 16 ++++++++++++++++ pnpm-lock.yaml | 6 ++++++ pnpm-workspace.yaml | 2 ++ 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/comark/package.json b/packages/comark/package.json index ba2df2105..315c0bbcc 100644 --- a/packages/comark/package.json +++ b/packages/comark/package.json @@ -68,6 +68,7 @@ "release:dry": "release-it --dry-run" }, "dependencies": { + "entities": "catalog:", "js-yaml": "catalog:", "markdown-exit": "catalog:" }, diff --git a/packages/comark/src/internal/parse/utils.ts b/packages/comark/src/internal/parse/utils.ts index ac1c5c11c..32c200fa8 100644 --- a/packages/comark/src/internal/parse/utils.ts +++ b/packages/comark/src/internal/parse/utils.ts @@ -1,6 +1,6 @@ import type { Node, ElementNode, ElementNodeAttributes, MarkdownDocument } from 'comark' +import { decodeHTMLAttribute } from 'entities' import { isHtmlVoidElement, TREE_WALK_MAX_DEPTH } from 'comark/utils' -import { MarkdownExit } from 'markdown-exit' export type ParsedHtmlTag = | { kind: 'open'; tag: string; attrs: Record; selfClosing: boolean } @@ -182,17 +182,13 @@ export function parseHtmlInline(content: string): ParsedHtmlTag { return { kind: 'other', content: trimmed } } -let unescapeAll: ((str: string) => string) | undefined - /** - * Decode HTML entities in a raw attribute value with markdown-exit's entity table. - * `unescapeAll` also strips markdown backslash escapes, so backslashes are doubled - * first to keep them literal, as HTML attributes have no backslash escapes. + * Decode character references in a raw attribute value the way browsers do: + * legacy semicolon-less references (`&`) are decoded, but not when followed by + * `=` or an alphanumeric (`?a=1©=2` stays literal). Backslashes are literal. */ function decodeAttributeValue(value: string): string { - if (!value.includes('&')) return value - unescapeAll ??= new MarkdownExit().utils.unescapeAll - return unescapeAll(value.replace(/\\/g, '\\\\')) + return value.includes('&') ? decodeHTMLAttribute(value) : value } /** diff --git a/packages/comark/test/html-escape.test.ts b/packages/comark/test/html-escape.test.ts index e1e01ddfd..788e5ed96 100644 --- a/packages/comark/test/html-escape.test.ts +++ b/packages/comark/test/html-escape.test.ts @@ -46,6 +46,22 @@ describe('HTML attribute escaping', () => { expect(span[1].title).toBe('C:\\*dir\\ & ©') }) + it('decodes HTML attribute entities with browser attribute semantics', async () => { + const attrsOf = async (markdown: string) => { + const tree = await parseMarkdown(markdown) + const p = tree.nodes[0] as [string, Record, ...Node[]] + return ((Array.isArray(p[2]) ? p[2] : p) as [string, Record, ...Node[]])[1] + } + + // Legacy semicolon-less reference is decoded... + expect((await attrsOf('hi')).title).toBe('A & B') + // ...but not when followed by `=` or an alphanumeric, so query strings survive. + expect((await attrsOf('hi')).href).toBe('?a=1©=2') + expect((await attrsOf('hi')).title).toBe('¬it;') + // Invalid code points become U+FFFD; references are decoded once. + expect((await attrsOf('hi')).title).toBe('\uFFFD|<') + }) + it('escapes quotes in component attribute props', async () => { const html = await renderHtml(`:span[hi]{title='a" onmouseover=alert(1) x="b'}`) expect(html).toContain('title="a" onmouseover=alert(1) x="b"') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26c106e6c..c0d49c1a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -317,6 +317,9 @@ catalogs: beautiful-mermaid: specifier: ^1.1.3 version: 1.1.3 + entities: + specifier: ^7.0.0 + version: 7.0.1 github-slugger: specifier: ^2.0.0 version: 2.0.0 @@ -1416,6 +1419,9 @@ importers: beautiful-mermaid: specifier: 'catalog:' version: 1.1.3 + entities: + specifier: 'catalog:' + version: 7.0.1 js-yaml: specifier: 'catalog:' version: 5.4.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 39872d675..f6bc2d6ab 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -90,6 +90,8 @@ catalog: comark: ^0.8.1 # Core comark dependencies + # Keep in the same major as markdown-exit's `entities` so installs dedupe to one copy. + entities: ^7.0.0 github-slugger: ^2.0.0 hast-util-to-string: ^3.0.1 js-yaml: ^5.4.2 From 8ff86069caf70574b79811ae83f3a7d1cea6f381 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Mon, 26 Oct 2026 14:56:41 +0100 Subject: [PATCH 5/6] feat(comark)!: accept a decodeHTML function in textContent BREAKING CHANGE: textContent() no longer accepts the decodeUnicodeEntities boolean. Pass a decoder instead, e.g. textContent(node, { decodeHTML }) with decodeHTML from entities. It is applied to each text node. --- .../1.getting-started/2.document-model.md | 9 ++++++ packages/comark/src/utils/index.ts | 21 +++++++++++-- packages/comark/test/text-content.test.ts | 31 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 packages/comark/test/text-content.test.ts diff --git a/docs/content/1.getting-started/2.document-model.md b/docs/content/1.getting-started/2.document-model.md index ec64434c2..dfa1b8b5c 100644 --- a/docs/content/1.getting-started/2.document-model.md +++ b/docs/content/1.getting-started/2.document-model.md @@ -250,6 +250,15 @@ const heading = document.nodes[0] console.log(textContent(heading)) // Hello world ``` +Text in parsed documents is already entity-decoded. For trees whose text may still contain character references, such as hand-built nodes, pass a `decodeHTML` function. It runs on each text node: + +```typescript [text-content-decode.ts] +import { decodeHTML } from 'entities' +import { textContent } from 'comark/utils' + +textContent(['p', {}, 'Tom & Jerry'], { decodeHTML }) // Tom & Jerry +``` + ### Visit nodes `visit()` walks every node that matches a checker. A visitor can inspect, replace, or remove matching nodes: diff --git a/packages/comark/src/utils/index.ts b/packages/comark/src/utils/index.ts index 5d0087f59..41743822d 100644 --- a/packages/comark/src/utils/index.ts +++ b/packages/comark/src/utils/index.ts @@ -11,20 +11,35 @@ export function isMarkdownDocument(value: unknown): value is MarkdownDocument { return !!value && typeof value === 'object' && Array.isArray((value as MarkdownDocument).nodes) } +export interface TextContentOptions { + /** + * Called on every text node before it is appended, e.g. `decodeHTML` from `entities`. + * Parsed documents already have decoded text, so only pass this for trees whose text + * may still contain character references (hand-built or from another source). + */ + decodeHTML?: (text: string) => string +} + /** * Get the text content of a Markdown AST node * * @param node - The node + * @param options - The options + * @param options.decodeHTML - Function applied to each text node * @returns The text content */ -export function textContent(node: Node): string { +export function textContent(node: Node, options?: TextContentOptions): string { + return collectText(node, options?.decodeHTML) +} + +function collectText(node: Node, decode: ((text: string) => string) | undefined): string { if (typeof node === 'string') { - return node + return decode ? decode(node) : node } let out = '' const len = node.length for (let i = 2; i < len; i++) { - out += textContent(node[i] as Node) + out += collectText(node[i] as Node, decode) } return out } diff --git a/packages/comark/test/text-content.test.ts b/packages/comark/test/text-content.test.ts new file mode 100644 index 000000000..c1a95cf77 --- /dev/null +++ b/packages/comark/test/text-content.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { decodeHTML } from 'entities' +import type { Node } from 'comark' +import { textContent } from '../src/utils' + +describe('textContent', () => { + const tree: Node = ['p', {}, 'Tom & ', ['strong', {}, 'Jerry'], ' ©'] + + it('concatenates text nodes', () => { + expect(textContent('plain')).toBe('plain') + expect(textContent(tree)).toBe('Tom & Jerry ©') + }) + + it('applies decodeHTML to every text node', () => { + expect(textContent(tree, { decodeHTML })).toBe('Tom & Jerry ©') + expect(textContent('<', { decodeHTML })).toBe('<') + }) + + it('decodes each text node separately', () => { + const calls: string[] = [] + const split: Node = ['p', {}, '&am', 'p;'] + const result = textContent(split, { + decodeHTML: (text) => { + calls.push(text) + return decodeHTML(text) + }, + }) + expect(calls).toEqual(['&am', 'p;']) + expect(result).toBe('&') + }) +}) From ab3a5c40bc2c7a17fcc977bd521787f0cd134cfb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:51:36 +0000 Subject: [PATCH 6/6] test: update bundle size snapshot --- test/bundle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index f70e6b6fc..c6aebd066 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -67,7 +67,7 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/react": "38.6k (76 files)", "@comark/svelte": "46.8k (84 files)", "@comark/vue": "56.4k (80 files)", - "comark": "389k (152 files)", + "comark": "390k (152 files)", } `) })