From fe3e4638fcf131d080380ce5720ef7ba873740d7 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 11 Sep 2026 14:24:00 -0600 Subject: [PATCH] Fix Markdown integration gaps within bundle budgets --- .changeset/portable-markdown-integrations.md | 13 + README.md | 8 +- docs/comparison.md | 10 +- docs/core-concepts/security.md | 26 ++ docs/guides/extensions.md | 27 +- docs/guides/performance.md | 10 +- docs/guides/syntax-highlighting.md | 4 + docs/overview.md | 2 +- docs/reference/parser.md | 1 + docs/reference/types.md | 18 +- reports/benchmarks.json | 292 +++++++++--------- reports/benchmarks.md | 164 +++++----- reports/conformance.json | 2 +- reports/conformance.md | 2 +- reports/external-corpus.json | 232 +++++++------- reports/external-corpus.md | 20 +- reports/sizes.json | 68 ++-- reports/sizes.md | 24 +- scripts/audit-corpus.ts | 5 +- scripts/conformance-data.ts | 2 +- skills/custom-extensions/SKILL.md | 13 +- .../docs-features/references/docs-metadata.md | 2 +- skills/production-pipelines/SKILL.md | 8 + .../references/ast-and-options.md | 9 +- src/html.ts | 16 +- src/inline.ts | 5 +- src/octane.ts | 14 +- src/react.ts | 20 +- src/types.ts | 12 + src/utils.ts | 7 +- tests/audit-regressions.test.tsx | 9 +- tests/bundle-size.test.ts | 40 +-- tests/corpus-audit.test.ts | 10 +- tests/inline-component.types.ts | 21 ++ tests/issue-regressions.test.tsx | 162 ++++++++++ tests/markdown.test.tsx | 2 +- tests/url-policy.test.tsx | 86 ++++++ 37 files changed, 890 insertions(+), 476 deletions(-) create mode 100644 .changeset/portable-markdown-integrations.md create mode 100644 tests/inline-component.types.ts create mode 100644 tests/issue-regressions.test.tsx create mode 100644 tests/url-policy.test.tsx diff --git a/.changeset/portable-markdown-integrations.md b/.changeset/portable-markdown-integrations.md new file mode 100644 index 0000000..56fbd3c --- /dev/null +++ b/.changeset/portable-markdown-integrations.md @@ -0,0 +1,13 @@ +--- +'@tanstack/markdown': patch +--- + +Forward raw code-fence metadata to `pre[data-meta]` and highlighter options in HTML, React, and Octane. + +Add portable `InlineComponentNode` output for custom inline extensions, using the existing component maps without requiring raw HTML or a bundled math engine. + +Add `urlTransform(url, kind, defaultUrl)` for application-controlled link and image policies. Default URL screening is unchanged; returning `null` removes a link or image while keeping its label content. + +Use single-pass attribute escaping, expand renderer and security regression coverage, and update documentation and shipped skills. The combined renderer increase is 74-78 gzip bytes, with no new runtime dependencies. + +Code fences with metadata now include an additional escaped HTML attribute. Consumers with exhaustive `InlineNode` switches should handle the new `inlineComponent` variant. diff --git a/README.md b/README.md index 2b7e701..2534c40 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,10 @@ A tiny, fast, deterministic Markdown parser and renderer for blogs and documentation. -- 4.9 KB gzip parser -- 6.7 KB gzip HTML renderer -- 6.6 KB gzip React adapter -- 6.6 KB gzip Octane adapter +- 5.0 KB gzip parser +- 6.8 KB gzip HTML renderer +- 6.7 KB gzip React adapter +- 6.7 KB gzip Octane adapter - zero runtime dependencies - serializable AST - safe defaults for raw HTML and executable URLs diff --git a/docs/comparison.md b/docs/comparison.md index 6aa5e05..e2cb35c 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -40,11 +40,11 @@ These repository benchmarks bundle representative browser entry points from pinn | Entry | Gzip | Brotli | | --- | ---: | ---: | -| `@tanstack/markdown/parser` | 4.9 KB | 4.6 KB | -| `@tanstack/markdown/html` | 6.7 KB | 6.2 KB | -| `@tanstack/markdown/react` | 6.6 KB | 6.1 KB | -| React with streaming extension | 6.8 KB | 6.3 KB | -| `@tanstack/markdown/octane` | 6.6 KB | 6.1 KB | +| `@tanstack/markdown/parser` | 5.0 KB | 4.6 KB | +| `@tanstack/markdown/html` | 6.8 KB | 6.2 KB | +| `@tanstack/markdown/react` | 6.7 KB | 6.2 KB | +| React with streaming extension | 6.9 KB | 6.4 KB | +| `@tanstack/markdown/octane` | 6.7 KB | 6.2 KB | | Marked | 12.5 KB | 11.5 KB | | micromark | 15.4 KB | 13.7 KB | | markdown-wasm JS + WASM | 31.3 KB | 26.4 KB | diff --git a/docs/core-concepts/security.md b/docs/core-concepts/security.md index a4c6fc6..2276d81 100644 --- a/docs/core-concepts/security.md +++ b/docs/core-concepts/security.md @@ -17,6 +17,30 @@ By default: Relative URLs, fragments, HTTP, HTTPS, email, and telephone links remain available. Other explicit protocols, including `data:`, are removed. +## Custom URL policy + +`urlTransform(url, kind, defaultUrl)` runs while parsing Markdown link and image destinations, including reference destinations. It receives the parsed URL before screening, `'link'` or `'image'`, and the result of the built-in URL policy. Return `defaultUrl` to keep that policy, a nonempty string to replace it, or `null` to remove the link or image while keeping its label content. An empty string keeps the existing empty-URL behavior: images have an empty `src`, nonempty link destinations lose their wrapper, and empty source destinations remain empty. Prefer `null` when rejecting a URL. + +For example, an application can allow only images it has already validated: + +```ts +import { renderHtml } from '@tanstack/markdown' +import type { UrlTransform } from '@tanstack/markdown' + +function imagePolicy(approvedImages: ReadonlySet): UrlTransform { + return (url, kind, defaultUrl) => + kind === 'image' && approvedImages.has(url) ? url : defaultUrl +} + +const html = renderHtml('![Logo](data:image/png;base64,...)', { + urlTransform: imagePolicy(new Set()), +}) +``` + +Populate the set only after validating image content, MIME type, and payload size. Do not approve every `data:` URL or populate it from untrusted Markdown. Callback results are trusted and are not screened again, though renderers still escape attribute values. Returning `defaultUrl` for links preserves the built-in protocol restrictions. + +The callback is synchronous and runs before inline transforms. It does not screen raw HTML, extension-created URLs, image-alt markup, or an AST supplied directly to a renderer. It also does not rewrite generated heading and footnote anchors. Keep callbacks deterministic and free of side effects; parsing a nested link can inspect a destination that does not become a rendered link. Enabling raw HTML is not an image-policy control. + ## Raw HTML `allowHtml: true` is an explicit trusted-content boundary: @@ -47,6 +71,8 @@ An extension `renderHtml` hook also returns trusted HTML. React and Octane compo Renderers trust document ASTs supplied directly by the application. URL screening happens during Markdown parsing, not when rendering an arbitrary link or image node. Do not accept untrusted JSON as a document AST without validating its structure and applying your URL and HTML policies. +An application-validated image node can contain a `data:` URL and render through HTML, React, or Octane without `allowHtml`. A custom parser can supply a validated `MarkdownDocument` directly. For Markdown strings, use `urlTransform` instead of trying to recover removed URLs in an inline transform. + ## Resource limits The core limits parser nesting and inline delimiter scans. These are not a limit on total input size, footnote count, or work performed by extensions. Bound untrusted document sizes in the application, and batch streaming updates instead of rerendering on every incoming character. diff --git a/docs/guides/extensions.md b/docs/guides/extensions.md index 855d626..fc00090 100644 --- a/docs/guides/extensions.md +++ b/docs/guides/extensions.md @@ -44,6 +44,8 @@ Nested parsing shares the parser depth budget and heading slugger. `transformInline` receives built-in inline nodes after parsing. Return the replacement array. Keep transforms deterministic and avoid repeated full-array scans for every node. +The hook runs once per inline container. Recurse through inline `children` when your transform also needs to handle content inside emphasis or links. Code spans and image alt text are not separate inline containers. + ## Document transformation `transformDocument` runs after all blocks and footnotes are built. Return a new `MarkdownDocument`, mutate and return nothing, or leave the document unchanged. The built-in heading collector uses this phase. @@ -52,7 +54,30 @@ Nested parsing shares the parser depth budget and heading slugger. `renderHtml` runs before built-in HTML node rendering. Return a string to claim the node or `undefined` to continue with the standard renderer. The context can render nested block and inline nodes. -This hook is HTML-specific. A custom node intended for both outputs should use a `ComponentNode` and map its tag through React `components`, or maintain an explicit React rendering layer. Returned HTML is trusted and is not sanitized. +This hook is HTML-specific. Returned HTML is trusted and is not sanitized. + +## Custom components + +Use `ComponentNode` for block content and `InlineComponentNode` for inline content. Both carry a `name`, source `attributes`, rendered string `properties`, and an optional `tagName`. Inline components have inline `children`, so they can appear in paragraphs, headings, links, and table cells without adding block wrappers. + +```ts +import type { InlineComponentNode } from '@tanstack/markdown' + +const badge: InlineComponentNode = { + type: 'inlineComponent', + name: 'status', + tagName: 'md-status', + attributes: {}, + properties: { 'data-state': 'ready' }, + children: [{ type: 'text', value: 'Ready' }], +} +``` + +An inline transform can return this node alongside ordinary text. The HTML renderer emits `Ready`. React and Octane use the same tag by default; map `'md-status'` through their `components` option to replace it. An HTML `renderHtml` hook can replace the same node for non-framework output. + +Without `tagName`, inline components fall back to `` and block components to ``, with `data-component` and JSON `data-attributes`. Values and text children are escaped. Tag and property names come from trusted extension code, not untrusted Markdown. Choose phrasing tags and inline-compatible replacements for inline nodes. + +These nodes are JSON-serializable and do not require `allowHtml`. Math parsing and rendering can use them in an opt-in extension, but neither a math parser nor a rendering engine is included in the core. ## Ordering diff --git a/docs/guides/performance.md b/docs/guides/performance.md index ea5dfaa..070f162 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -12,11 +12,11 @@ The generated browser bundle report records: | Entry | Gzip | Brotli | | --- | ---: | ---: | -| parser | 4.9 KB | 4.6 KB | -| HTML renderer | 6.7 KB | 6.2 KB | -| React adapter | 6.6 KB | 6.1 KB | -| Octane adapter | 6.6 KB | 6.1 KB | -| React adapter with streaming extension | 6.8 KB | 6.3 KB | +| parser | 5.0 KB | 4.6 KB | +| HTML renderer | 6.8 KB | 6.2 KB | +| React adapter | 6.7 KB | 6.2 KB | +| Octane adapter | 6.7 KB | 6.2 KB | +| React adapter with streaming extension | 6.9 KB | 6.4 KB | | Streaming extension | 0.3 KB | 0.3 KB | | docs preset | 2.3 KB | 2.1 KB | | callouts extension | 0.3 KB | 0.3 KB | diff --git a/docs/guides/syntax-highlighting.md b/docs/guides/syntax-highlighting.md index 79c0656..c5c83e9 100644 --- a/docs/guides/syntax-highlighting.md +++ b/docs/guides/syntax-highlighting.md @@ -120,6 +120,10 @@ const two = 2 The parser records metadata even when no highlighter is configured. This keeps content parsing independent from presentation and lets a build pipeline change highlighters without rebuilding the AST. +The complete fence metadata string is available as `CodeBlockNode.meta`, `options.meta` in the highlighter callback, and `data-meta` on the rendered `
` in HTML, React, and Octane. Framework `pre` replacements can read `props['data-meta']`. The attribute and callback field are omitted when there is no metadata. Existing title, filename, framework, and highlighted-line fields remain available.
+
+Metadata is source text, not executable configuration. Validate any values you use to select a custom renderer; never evaluate metadata as code.
+
 ## Keep it server-only when possible
 
 For static blogs and docs, parse and highlight during a build or server render, then send the finished markup. The Markdown package does not force highlighting or registered language grammars into client bundles.
diff --git a/docs/overview.md b/docs/overview.md
index 152b95e..f814fbb 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -31,7 +31,7 @@ TanStack Markdown spends its complexity budget on that path. It deliberately doe
 
 ### Small entry points
 
-Current minified browser bundles are 4.9 KB gzip for the parser, 6.7 KB for HTML rendering, and 6.6 KB for either UI adapter with its framework runtime externalized. The generated [bundle report](https://github.com/TanStack/markdown/blob/main/reports/sizes.md) is the source of truth.
+Current minified browser bundles are 5.0 KB gzip for the parser, 6.8 KB for HTML rendering, and 6.7 KB for either UI adapter with its framework runtime externalized. The generated [bundle report](https://github.com/TanStack/markdown/blob/main/reports/sizes.md) is the source of truth.
 
 ### Parse once, render many
 
diff --git a/docs/reference/parser.md b/docs/reference/parser.md
index eb70467..6747c9b 100644
--- a/docs/reference/parser.md
+++ b/docs/reference/parser.md
@@ -26,6 +26,7 @@ Normalizes and parses a complete Markdown source string into a deterministic `Ma
 | Option | Default | Behavior |
 | --- | --- | --- |
 | `allowHtml` | `false` | Recognize raw block and inline HTML nodes |
+| `urlTransform` | built-in policy | Override parsed link and image URLs; see [Custom URL policy](../core-concepts/security#custom-url-policy) |
 | `frontmatter` | `true` | Extract a leading `---` frontmatter block |
 | `headingIds` | `true` | Generate IDs, disable them, or provide an ID function |
 | `extensions` | `[]` | Run custom parser and transform hooks in array order |
diff --git a/docs/reference/types.md b/docs/reference/types.md
index d36a0ab..fd7a737 100644
--- a/docs/reference/types.md
+++ b/docs/reference/types.md
@@ -22,7 +22,7 @@ Union of `HeadingNode`, `ParagraphNode`, `CodeBlockNode`, `ListNode`, `Blockquot
 
 ### `InlineNode`
 
-Union of `TextNode`, `CodeSpanNode`, `StrongNode`, `EmphasisNode`, `StrikeNode`, `FootnoteReferenceNode`, `LinkNode`, `ImageNode`, `BreakNode`, and `HtmlInlineNode`.
+Union of `TextNode`, `CodeSpanNode`, `StrongNode`, `EmphasisNode`, `StrikeNode`, `FootnoteReferenceNode`, `LinkNode`, `ImageNode`, `BreakNode`, `HtmlInlineNode`, and `InlineComponentNode`.
 
 ## Block nodes
 
@@ -110,11 +110,11 @@ Contains normalized footnote `id`, display `number`, and optional `referenceInde
 
 ### `LinkNode`
 
-Contains sanitized `href`, optional `title`, and inline `children`.
+Contains policy-processed `href`, optional `title`, and inline `children`.
 
 ### `ImageNode`
 
-Contains sanitized `src`, text `alt`, and optional `title`.
+Contains policy-processed `src`, text `alt`, and optional `title`.
 
 ### `BreakNode`
 
@@ -124,11 +124,19 @@ Marker node with `type: 'break'`.
 
 Contains raw inline HTML `value`. It is created only when HTML parsing is enabled.
 
+### `InlineComponentNode`
+
+Contains `type: 'inlineComponent'`, `name`, source `attributes`, inline `children`, and optional rendered `tagName` and string `properties`. Uses the same component replacements as `ComponentNode`, with a `` fallback when no tag is provided. See [Custom components](../guides/extensions#custom-components).
+
 ## Parsing and rendering options
 
 ### `ParseOptions`
 
-Configures `allowHtml`, `frontmatter`, `headingIds`, and `extensions`. It also exposes `references`, `footnotes`, `footnoteOrder`, and `footnoteCounts` state used by nested parser contexts.
+Configures `allowHtml`, `urlTransform`, `frontmatter`, `headingIds`, and `extensions`. It also exposes `references`, `footnotes`, `footnoteOrder`, and `footnoteCounts` state used by nested parser contexts.
+
+### `UrlTransform`
+
+Synchronous callback `(url: string, kind: 'link' | 'image', defaultUrl: string) => string | null`. Return the default screened URL, a trusted replacement, or `null` to keep only the label content. Applies during Markdown parsing, not to raw HTML or supplied ASTs. See [Custom URL policy](../core-concepts/security#custom-url-policy).
 
 ### `RenderOptions`
 
@@ -140,7 +148,7 @@ Synchronous callback `(code, lang?, options?) => string`. The returned string is
 
 ### `CodeHighlightOptions`
 
-Contains optional `highlightLines` and `lineNumbers` passed to a `CodeHighlighter`.
+Contains optional raw fence `meta`, `highlightLines`, and `lineNumbers` passed to a `CodeHighlighter`.
 
 ### `HeadingAnchorOptions`
 
diff --git a/reports/benchmarks.json b/reports/benchmarks.json
index 4241839..1ba7450 100644
--- a/reports/benchmarks.json
+++ b/reports/benchmarks.json
@@ -1,6 +1,6 @@
 {
-  "generatedAt": "2026-09-11T18:44:31.005Z",
-  "sink": 112177610,
+  "generatedAt": "2026-09-11T20:23:30.357Z",
+  "sink": 112892330,
   "results": [
     {
       "group": "markdown",
@@ -8,9 +8,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 2000,
-      "msPerOp": 0.016327895999999995,
+      "msPerOp": 0.01844806249999999,
       "outputBytes": 15,
-      "heapDeltaKb": 315.3359375
+      "heapDeltaKb": 898.9609375
     },
     {
       "group": "markdown",
@@ -18,9 +18,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 2000,
-      "msPerOp": 0.004077645500000003,
+      "msPerOp": 0.005263854499999994,
       "outputBytes": 1399,
-      "heapDeltaKb": 4611.7109375
+      "heapDeltaKb": 1238.703125
     },
     {
       "group": "markdown",
@@ -28,9 +28,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 2000,
-      "msPerOp": 0.003343041999999997,
+      "msPerOp": 0.00399779199999999,
       "outputBytes": 1143,
-      "heapDeltaKb": 23.9765625
+      "heapDeltaKb": -3027.4296875
     },
     {
       "group": "markdown",
@@ -38,9 +38,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 2000,
-      "msPerOp": 0.020055771000000007,
+      "msPerOp": 0.023797770500000013,
       "outputBytes": 1399,
-      "heapDeltaKb": 545.7890625
+      "heapDeltaKb": -2596.171875
     },
     {
       "group": "markdown",
@@ -48,9 +48,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 2000,
-      "msPerOp": 0.018646020999999992,
+      "msPerOp": 0.023606604000000003,
       "outputBytes": 1143,
-      "heapDeltaKb": 1626.9609375
+      "heapDeltaKb": -1860.5859375
     },
     {
       "group": "markdown",
@@ -58,9 +58,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 2000,
-      "msPerOp": 0.025917020500000006,
+      "msPerOp": 0.02952339599999999,
       "outputBytes": 1035,
-      "heapDeltaKb": -584.203125
+      "heapDeltaKb": -853.609375
     },
     {
       "group": "markdown",
@@ -68,9 +68,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 2000,
-      "msPerOp": 0.017147250000000013,
+      "msPerOp": 0.019339646000000016,
       "outputBytes": 1087,
-      "heapDeltaKb": -3580.1484375
+      "heapDeltaKb": 4088.890625
     },
     {
       "group": "markdown",
@@ -78,9 +78,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 2000,
-      "msPerOp": 0.14661381250000002,
+      "msPerOp": 0.17314687500000003,
       "outputBytes": 825,
-      "heapDeltaKb": 6402.0078125
+      "heapDeltaKb": -8298.203125
     },
     {
       "group": "markdown",
@@ -88,9 +88,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 2000,
-      "msPerOp": 0.011903479000000005,
+      "msPerOp": 0.021329833500000006,
       "outputBytes": 825,
-      "heapDeltaKb": 155.6796875
+      "heapDeltaKb": 54.8671875
     },
     {
       "group": "markdown",
@@ -98,9 +98,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 2000,
-      "msPerOp": 0.007521978999999988,
+      "msPerOp": 0.010540166499999998,
       "outputBytes": 1117,
-      "heapDeltaKb": 4372.796875
+      "heapDeltaKb": 4372.3984375
     },
     {
       "group": "markdown",
@@ -108,9 +108,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 2000,
-      "msPerOp": 0.16969152099999996,
+      "msPerOp": 0.19910575,
       "outputBytes": 824,
-      "heapDeltaKb": 14038.59375
+      "heapDeltaKb": 13756
     },
     {
       "group": "markdown",
@@ -118,9 +118,9 @@
       "fixture": "code-heavy.md",
       "bytes": 1011,
       "iterations": 1000,
-      "msPerOp": 0.006406916000000137,
+      "msPerOp": 0.006807167000000163,
       "outputBytes": 15,
-      "heapDeltaKb": -6268.078125
+      "heapDeltaKb": -6378.1484375
     },
     {
       "group": "markdown",
@@ -128,9 +128,9 @@
       "fixture": "code-heavy.md",
       "bytes": 1011,
       "iterations": 1000,
-      "msPerOp": 0.007081665999999814,
-      "outputBytes": 4504,
-      "heapDeltaKb": 347.3984375
+      "msPerOp": 0.007484500000000026,
+      "outputBytes": 4596,
+      "heapDeltaKb": -1122.5703125
     },
     {
       "group": "markdown",
@@ -138,9 +138,9 @@
       "fixture": "code-heavy.md",
       "bytes": 1011,
       "iterations": 1000,
-      "msPerOp": 0.0034035000000001217,
-      "outputBytes": 1835,
-      "heapDeltaKb": 12183.8671875
+      "msPerOp": 0.003155708999999888,
+      "outputBytes": 1927,
+      "heapDeltaKb": 10476.2890625
     },
     {
       "group": "markdown",
@@ -148,9 +148,9 @@
       "fixture": "code-heavy.md",
       "bytes": 1011,
       "iterations": 1000,
-      "msPerOp": 0.01388537499999984,
-      "outputBytes": 4504,
-      "heapDeltaKb": -5203.2265625
+      "msPerOp": 0.014249915999999984,
+      "outputBytes": 4596,
+      "heapDeltaKb": -6339.6796875
     },
     {
       "group": "markdown",
@@ -158,9 +158,9 @@
       "fixture": "code-heavy.md",
       "bytes": 1011,
       "iterations": 1000,
-      "msPerOp": 0.010949084000000085,
-      "outputBytes": 1835,
-      "heapDeltaKb": -26450.2265625
+      "msPerOp": 0.009854332999999996,
+      "outputBytes": 1927,
+      "heapDeltaKb": 4603.2109375
     },
     {
       "group": "markdown",
@@ -168,9 +168,9 @@
       "fixture": "code-heavy.md",
       "bytes": 1011,
       "iterations": 1000,
-      "msPerOp": 0.005920415999999932,
+      "msPerOp": 0.006169208000000026,
       "outputBytes": 1330,
-      "heapDeltaKb": 12896.25
+      "heapDeltaKb": -19757.0859375
     },
     {
       "group": "markdown",
@@ -178,9 +178,9 @@
       "fixture": "code-heavy.md",
       "bytes": 1011,
       "iterations": 1000,
-      "msPerOp": 0.00877541599999995,
+      "msPerOp": 0.008338584000000083,
       "outputBytes": 1330,
-      "heapDeltaKb": -5356.265625
+      "heapDeltaKb": -5321.0390625
     },
     {
       "group": "markdown",
@@ -188,9 +188,9 @@
       "fixture": "code-heavy.md",
       "bytes": 1011,
       "iterations": 1000,
-      "msPerOp": 0.14078658300000008,
+      "msPerOp": 0.13680366700000013,
       "outputBytes": 1330,
-      "heapDeltaKb": -11812.15625
+      "heapDeltaKb": -11833.328125
     },
     {
       "group": "markdown",
@@ -198,9 +198,9 @@
       "fixture": "code-heavy.md",
       "bytes": 1011,
       "iterations": 1000,
-      "msPerOp": 0.009368208000000095,
+      "msPerOp": 0.009240166999999928,
       "outputBytes": 1330,
-      "heapDeltaKb": -8107.4375
+      "heapDeltaKb": 24577.0234375
     },
     {
       "group": "markdown",
@@ -208,9 +208,9 @@
       "fixture": "code-heavy.md",
       "bytes": 1011,
       "iterations": 1000,
-      "msPerOp": 0.004259957999999869,
+      "msPerOp": 0.004289874999999938,
       "outputBytes": 1512,
-      "heapDeltaKb": 2558.390625
+      "heapDeltaKb": 2559.9921875
     },
     {
       "group": "markdown",
@@ -218,9 +218,9 @@
       "fixture": "code-heavy.md",
       "bytes": 1011,
       "iterations": 1000,
-      "msPerOp": 0.14871712500000012,
+      "msPerOp": 0.15214312500000005,
       "outputBytes": 1200,
-      "heapDeltaKb": -6915
+      "heapDeltaKb": -7037.953125
     },
     {
       "group": "markdown",
@@ -228,9 +228,9 @@
       "fixture": "malformed.md",
       "bytes": 237,
       "iterations": 2000,
-      "msPerOp": 0.002287645999999995,
+      "msPerOp": 0.0027057704999999713,
       "outputBytes": 15,
-      "heapDeltaKb": 18583.7578125
+      "heapDeltaKb": 18580.609375
     },
     {
       "group": "markdown",
@@ -238,9 +238,9 @@
       "fixture": "malformed.md",
       "bytes": 237,
       "iterations": 2000,
-      "msPerOp": 0.0018336250000000973,
+      "msPerOp": 0.0016762294999999768,
       "outputBytes": 1067,
-      "heapDeltaKb": -16217.9140625
+      "heapDeltaKb": -17067.3203125
     },
     {
       "group": "markdown",
@@ -248,9 +248,9 @@
       "fixture": "malformed.md",
       "bytes": 237,
       "iterations": 2000,
-      "msPerOp": 0.0007485414999999875,
+      "msPerOp": 0.0007344790000000785,
       "outputBytes": 361,
-      "heapDeltaKb": 6298.890625
+      "heapDeltaKb": 5314.8828125
     },
     {
       "group": "markdown",
@@ -258,9 +258,9 @@
       "fixture": "malformed.md",
       "bytes": 237,
       "iterations": 2000,
-      "msPerOp": 0.0042109375,
+      "msPerOp": 0.004606062500000007,
       "outputBytes": 1067,
-      "heapDeltaKb": 2330.59375
+      "heapDeltaKb": 1452.21875
     },
     {
       "group": "markdown",
@@ -268,9 +268,9 @@
       "fixture": "malformed.md",
       "bytes": 237,
       "iterations": 2000,
-      "msPerOp": 0.0031345420000000106,
+      "msPerOp": 0.0036469999999999346,
       "outputBytes": 361,
-      "heapDeltaKb": -7910.234375
+      "heapDeltaKb": -8892.2109375
     },
     {
       "group": "markdown",
@@ -278,9 +278,9 @@
       "fixture": "malformed.md",
       "bytes": 237,
       "iterations": 2000,
-      "msPerOp": 0.004964625000000069,
+      "msPerOp": 0.005531416499999977,
       "outputBytes": 350,
-      "heapDeltaKb": -16034.546875
+      "heapDeltaKb": 16140.453125
     },
     {
       "group": "markdown",
@@ -288,9 +288,9 @@
       "fixture": "malformed.md",
       "bytes": 237,
       "iterations": 2000,
-      "msPerOp": 0.004126375000000053,
+      "msPerOp": 0.005058562499999994,
       "outputBytes": 300,
-      "heapDeltaKb": 24762.1640625
+      "heapDeltaKb": -7253.6015625
     },
     {
       "group": "markdown",
@@ -298,9 +298,9 @@
       "fixture": "malformed.md",
       "bytes": 237,
       "iterations": 2000,
-      "msPerOp": 0.04548495850000006,
+      "msPerOp": 0.05207070850000002,
       "outputBytes": 300,
-      "heapDeltaKb": -24868.6953125
+      "heapDeltaKb": 7694.546875
     },
     {
       "group": "markdown",
@@ -308,9 +308,9 @@
       "fixture": "malformed.md",
       "bytes": 237,
       "iterations": 2000,
-      "msPerOp": 0.003408083499999975,
+      "msPerOp": 0.0038984790000000658,
       "outputBytes": 300,
-      "heapDeltaKb": 23073.328125
+      "heapDeltaKb": -9655.921875
     },
     {
       "group": "markdown",
@@ -318,9 +318,9 @@
       "fixture": "malformed.md",
       "bytes": 237,
       "iterations": 2000,
-      "msPerOp": 0.0018331875000000082,
+      "msPerOp": 0.0018649789999999485,
       "outputBytes": 408,
-      "heapDeltaKb": 2938.84375
+      "heapDeltaKb": 2938.3984375
     },
     {
       "group": "markdown",
@@ -328,9 +328,9 @@
       "fixture": "malformed.md",
       "bytes": 237,
       "iterations": 2000,
-      "msPerOp": 0.04937491649999993,
+      "msPerOp": 0.06917156250000005,
       "outputBytes": 297,
-      "heapDeltaKb": 1974.71875
+      "heapDeltaKb": 2219.1953125
     },
     {
       "group": "markdown",
@@ -338,9 +338,9 @@
       "fixture": "prose-heavy.md",
       "bytes": 1700,
       "iterations": 1000,
-      "msPerOp": 0.013079917000000022,
+      "msPerOp": 0.017162290999999868,
       "outputBytes": 15,
-      "heapDeltaKb": 15263.734375
+      "heapDeltaKb": -17554.0625
     },
     {
       "group": "markdown",
@@ -348,9 +348,9 @@
       "fixture": "prose-heavy.md",
       "bytes": 1700,
       "iterations": 1000,
-      "msPerOp": 0.002199666999999863,
+      "msPerOp": 0.002673625000000129,
       "outputBytes": 1903,
-      "heapDeltaKb": -20948.8515625
+      "heapDeltaKb": 10295.9765625
     },
     {
       "group": "markdown",
@@ -358,9 +358,9 @@
       "fixture": "prose-heavy.md",
       "bytes": 1700,
       "iterations": 1000,
-      "msPerOp": 0.0022082499999999073,
+      "msPerOp": 0.002687332999999853,
       "outputBytes": 1903,
-      "heapDeltaKb": 11793.546875
+      "heapDeltaKb": 10321.3203125
     },
     {
       "group": "markdown",
@@ -368,9 +368,9 @@
       "fixture": "prose-heavy.md",
       "bytes": 1700,
       "iterations": 1000,
-      "msPerOp": 0.016069999999999935,
+      "msPerOp": 0.017694124999999984,
       "outputBytes": 1903,
-      "heapDeltaKb": -5901.640625
+      "heapDeltaKb": -7354.9765625
     },
     {
       "group": "markdown",
@@ -378,9 +378,9 @@
       "fixture": "prose-heavy.md",
       "bytes": 1700,
       "iterations": 1000,
-      "msPerOp": 0.01590337499999987,
+      "msPerOp": 0.016582792000000155,
       "outputBytes": 1903,
-      "heapDeltaKb": -5896.0078125
+      "heapDeltaKb": -7244.9453125
     },
     {
       "group": "markdown",
@@ -388,9 +388,9 @@
       "fixture": "prose-heavy.md",
       "bytes": 1700,
       "iterations": 1000,
-      "msPerOp": 0.035356667000000015,
+      "msPerOp": 0.03447937500000012,
       "outputBytes": 1860,
-      "heapDeltaKb": 6719.390625
+      "heapDeltaKb": 6729.9375
     },
     {
       "group": "markdown",
@@ -398,9 +398,9 @@
       "fixture": "prose-heavy.md",
       "bytes": 1700,
       "iterations": 1000,
-      "msPerOp": 0.01706950000000006,
+      "msPerOp": 0.01885091699999998,
       "outputBytes": 1860,
-      "heapDeltaKb": 14302.8671875
+      "heapDeltaKb": -18343.15625
     },
     {
       "group": "markdown",
@@ -408,9 +408,9 @@
       "fixture": "prose-heavy.md",
       "bytes": 1700,
       "iterations": 1000,
-      "msPerOp": 0.22802695800000014,
+      "msPerOp": 0.24291566599999986,
       "outputBytes": 1862,
-      "heapDeltaKb": 84947.015625
+      "heapDeltaKb": 24865.828125
     },
     {
       "group": "markdown",
@@ -418,9 +418,9 @@
       "fixture": "prose-heavy.md",
       "bytes": 1700,
       "iterations": 1000,
-      "msPerOp": 0.012153291999999965,
+      "msPerOp": 0.011920082999999977,
       "outputBytes": 1862,
-      "heapDeltaKb": -13384.6015625
+      "heapDeltaKb": 46458.671875
     },
     {
       "group": "markdown",
@@ -428,9 +428,9 @@
       "fixture": "prose-heavy.md",
       "bytes": 1700,
       "iterations": 1000,
-      "msPerOp": 0.006072957999999971,
+      "msPerOp": 0.006297250000000076,
       "outputBytes": 2340,
-      "heapDeltaKb": 3368.1953125
+      "heapDeltaKb": 3368.3671875
     },
     {
       "group": "markdown",
@@ -438,9 +438,9 @@
       "fixture": "prose-heavy.md",
       "bytes": 1700,
       "iterations": 1000,
-      "msPerOp": 0.249780209,
+      "msPerOp": 0.28966795899999986,
       "outputBytes": 1859,
-      "heapDeltaKb": 50851.1171875
+      "heapDeltaKb": 50897.1328125
     },
     {
       "group": "markdown",
@@ -448,9 +448,9 @@
       "fixture": "small-doc.md",
       "bytes": 432,
       "iterations": 2000,
-      "msPerOp": 0.00894247949999999,
+      "msPerOp": 0.0093737500000002,
       "outputBytes": 15,
-      "heapDeltaKb": 7608.5390625
+      "heapDeltaKb": 7577.4453125
     },
     {
       "group": "markdown",
@@ -458,9 +458,9 @@
       "fixture": "small-doc.md",
       "bytes": 432,
       "iterations": 2000,
-      "msPerOp": 0.0029711039999999684,
-      "outputBytes": 1328,
-      "heapDeltaKb": 30267.296875
+      "msPerOp": 0.003080417000000125,
+      "outputBytes": 1370,
+      "heapDeltaKb": 27814.0859375
     },
     {
       "group": "markdown",
@@ -468,9 +468,9 @@
       "fixture": "small-doc.md",
       "bytes": 432,
       "iterations": 2000,
-      "msPerOp": 0.0025746250000001965,
-      "outputBytes": 1002,
-      "heapDeltaKb": -39909.1640625
+      "msPerOp": 0.0026590625000001184,
+      "outputBytes": 1044,
+      "heapDeltaKb": -42766.875
     },
     {
       "group": "markdown",
@@ -478,9 +478,9 @@
       "fixture": "small-doc.md",
       "bytes": 432,
       "iterations": 2000,
-      "msPerOp": 0.01206379199999992,
-      "outputBytes": 1328,
-      "heapDeltaKb": 33857.9609375
+      "msPerOp": 0.012628958000000011,
+      "outputBytes": 1370,
+      "heapDeltaKb": 31414.875
     },
     {
       "group": "markdown",
@@ -488,9 +488,9 @@
       "fixture": "small-doc.md",
       "bytes": 432,
       "iterations": 2000,
-      "msPerOp": 0.011751125000000001,
-      "outputBytes": 1002,
-      "heapDeltaKb": -37329.8828125
+      "msPerOp": 0.012084312499999895,
+      "outputBytes": 1044,
+      "heapDeltaKb": -40133.328125
     },
     {
       "group": "markdown",
@@ -498,9 +498,9 @@
       "fixture": "small-doc.md",
       "bytes": 432,
       "iterations": 2000,
-      "msPerOp": 0.012103583000000072,
+      "msPerOp": 0.012065562499999943,
       "outputBytes": 724,
-      "heapDeltaKb": -14790.46875
+      "heapDeltaKb": 50632.2109375
     },
     {
       "group": "markdown",
@@ -508,9 +508,9 @@
       "fixture": "small-doc.md",
       "bytes": 432,
       "iterations": 2000,
-      "msPerOp": 0.007528125000000045,
+      "msPerOp": 0.00776693750000004,
       "outputBytes": 776,
-      "heapDeltaKb": 3163.140625
+      "heapDeltaKb": 3160.15625
     },
     {
       "group": "markdown",
@@ -518,9 +518,9 @@
       "fixture": "small-doc.md",
       "bytes": 432,
       "iterations": 2000,
-      "msPerOp": 0.09641347900000005,
+      "msPerOp": 0.09741200000000004,
       "outputBytes": 535,
-      "heapDeltaKb": 45302.109375
+      "heapDeltaKb": 44970.375
     },
     {
       "group": "markdown",
@@ -528,9 +528,9 @@
       "fixture": "small-doc.md",
       "bytes": 432,
       "iterations": 2000,
-      "msPerOp": 0.006531604500000185,
+      "msPerOp": 0.007062458500000048,
       "outputBytes": 535,
-      "heapDeltaKb": -15211.2890625
+      "heapDeltaKb": -15145.1796875
     },
     {
       "group": "markdown",
@@ -538,9 +538,9 @@
       "fixture": "small-doc.md",
       "bytes": 432,
       "iterations": 2000,
-      "msPerOp": 0.0033195419999999557,
+      "msPerOp": 0.003928562499999998,
       "outputBytes": 854,
-      "heapDeltaKb": 3642.8125
+      "heapDeltaKb": 3580.1796875
     },
     {
       "group": "markdown",
@@ -548,9 +548,9 @@
       "fixture": "small-doc.md",
       "bytes": 432,
       "iterations": 2000,
-      "msPerOp": 0.11191214549999995,
+      "msPerOp": 0.129259542,
       "outputBytes": 534,
-      "heapDeltaKb": -61627.6484375
+      "heapDeltaKb": 4179.84375
     },
     {
       "group": "markdown",
@@ -558,9 +558,9 @@
       "fixture": "tables-lists.md",
       "bytes": 454,
       "iterations": 2000,
-      "msPerOp": 0.022367541500000015,
+      "msPerOp": 0.0232009579999999,
       "outputBytes": 15,
-      "heapDeltaKb": 21773.0546875
+      "heapDeltaKb": -43744.359375
     },
     {
       "group": "markdown",
@@ -568,9 +568,9 @@
       "fixture": "tables-lists.md",
       "bytes": 454,
       "iterations": 2000,
-      "msPerOp": 0.004276541500000121,
+      "msPerOp": 0.004354895999999826,
       "outputBytes": 1315,
-      "heapDeltaKb": -18808.4296875
+      "heapDeltaKb": 42277.9921875
     },
     {
       "group": "markdown",
@@ -578,9 +578,9 @@
       "fixture": "tables-lists.md",
       "bytes": 454,
       "iterations": 2000,
-      "msPerOp": 0.003825853999999936,
+      "msPerOp": 0.00404427099999998,
       "outputBytes": 1315,
-      "heapDeltaKb": 46672.375
+      "heapDeltaKb": -23161.15625
     },
     {
       "group": "markdown",
@@ -588,9 +588,9 @@
       "fixture": "tables-lists.md",
       "bytes": 454,
       "iterations": 2000,
-      "msPerOp": 0.026598353999999973,
+      "msPerOp": 0.02736354150000011,
       "outputBytes": 1315,
-      "heapDeltaKb": 2395.0546875
+      "heapDeltaKb": -1853.140625
     },
     {
       "group": "markdown",
@@ -598,9 +598,9 @@
       "fixture": "tables-lists.md",
       "bytes": 454,
       "iterations": 2000,
-      "msPerOp": 0.0264140625,
+      "msPerOp": 0.027197875000000066,
       "outputBytes": 1315,
-      "heapDeltaKb": 2248.7734375
+      "heapDeltaKb": -2112.7109375
     },
     {
       "group": "markdown",
@@ -608,9 +608,9 @@
       "fixture": "tables-lists.md",
       "bytes": 454,
       "iterations": 2000,
-      "msPerOp": 0.02775885449999987,
+      "msPerOp": 0.029182874999999966,
       "outputBytes": 1102,
-      "heapDeltaKb": -14314.8203125
+      "heapDeltaKb": -14329.2421875
     },
     {
       "group": "markdown",
@@ -618,9 +618,9 @@
       "fixture": "tables-lists.md",
       "bytes": 454,
       "iterations": 2000,
-      "msPerOp": 0.01780399999999986,
+      "msPerOp": 0.017972145500000123,
       "outputBytes": 1325,
-      "heapDeltaKb": -39188.6875
+      "heapDeltaKb": 26395.5078125
     },
     {
       "group": "markdown",
@@ -628,9 +628,9 @@
       "fixture": "tables-lists.md",
       "bytes": 454,
       "iterations": 2000,
-      "msPerOp": 0.16020520850000003,
+      "msPerOp": 0.16772906250000005,
       "outputBytes": 627,
-      "heapDeltaKb": 26000.8984375
+      "heapDeltaKb": -39153.3359375
     },
     {
       "group": "markdown",
@@ -638,9 +638,9 @@
       "fixture": "tables-lists.md",
       "bytes": 454,
       "iterations": 2000,
-      "msPerOp": 0.011145854000000099,
+      "msPerOp": 0.011388625000000048,
       "outputBytes": 627,
-      "heapDeltaKb": -37171.28125
+      "heapDeltaKb": 28363.8125
     },
     {
       "group": "markdown",
@@ -648,9 +648,9 @@
       "fixture": "tables-lists.md",
       "bytes": 454,
       "iterations": 2000,
-      "msPerOp": 0.00554710399999999,
+      "msPerOp": 0.005910791999999901,
       "outputBytes": 1202,
-      "heapDeltaKb": 4506
+      "heapDeltaKb": 4506.171875
     },
     {
       "group": "markdown",
@@ -658,9 +658,9 @@
       "fixture": "tables-lists.md",
       "bytes": 454,
       "iterations": 2000,
-      "msPerOp": 0.1868297500000001,
+      "msPerOp": 0.1964307080000001,
       "outputBytes": 622,
-      "heapDeltaKb": 7536.3515625
+      "heapDeltaKb": 7601.8671875
     },
     {
       "group": "streaming",
@@ -668,9 +668,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 250,
-      "msPerOp": 0.2125391640000016,
+      "msPerOp": 0.22356500000000232,
       "outputBytes": 1112,
-      "heapDeltaKb": 62452.2109375
+      "heapDeltaKb": -8191.453125
     },
     {
       "group": "streaming",
@@ -678,9 +678,9 @@
       "fixture": "ai-response.md",
       "bytes": 652,
       "iterations": 250,
-      "msPerOp": 0.2893066680000011,
+      "msPerOp": 0.2804184999999998,
       "outputBytes": 1035,
-      "heapDeltaKb": 3190
+      "heapDeltaKb": 3232.0078125
     }
   ]
 }
\ No newline at end of file
diff --git a/reports/benchmarks.md b/reports/benchmarks.md
index 3249da0..adb64ed 100644
--- a/reports/benchmarks.md
+++ b/reports/benchmarks.md
@@ -1,6 +1,6 @@
 # Benchmark Results
 
-Generated: 2026-09-11T18:44:31.009Z
+Generated: 2026-09-11T20:23:30.359Z
 
 Lower `ms/op` is better. Benchmarks run in Node with production package builds where available; heap delta is a coarse process-level signal, not an allocation profiler. Streaming rows replay the complete response in 32-character chunks, so one operation is one progressive response.
 
@@ -8,94 +8,94 @@ Lower `ms/op` is better. Benchmarks run in Node with production package builds w
 
 | Name | Fixture | Bytes | Iterations | ms/op | Output bytes | Heap delta KB |
 | :--- | :--- | ---: | ---: | ---: | ---: | ---: |
-| @tanstack/markdown parse | ai-response.md | 652 | 2000 | 0.0163 | 15 | 315.3 |
-| @tanstack/markdown render AST with external highlighter | ai-response.md | 652 | 2000 | 0.0041 | 1399 | 4611.7 |
-| @tanstack/markdown render AST | ai-response.md | 652 | 2000 | 0.0033 | 1143 | 24.0 |
-| @tanstack/markdown parse+render with external highlighter | ai-response.md | 652 | 2000 | 0.0201 | 1399 | 545.8 |
-| @tanstack/markdown parse+render | ai-response.md | 652 | 2000 | 0.0186 | 1143 | 1627.0 |
-| marked parse+render | ai-response.md | 652 | 2000 | 0.0259 | 1035 | -584.2 |
-| markdown-it parse+render | ai-response.md | 652 | 2000 | 0.0171 | 1087 | -3580.1 |
-| micromark render | ai-response.md | 652 | 2000 | 0.1466 | 825 | 6402.0 |
-| commonmark parse+render | ai-response.md | 652 | 2000 | 0.0119 | 825 | 155.7 |
-| markdown-wasm render | ai-response.md | 652 | 2000 | 0.0075 | 1117 | 4372.8 |
-| unified remark+rehype render | ai-response.md | 652 | 2000 | 0.1697 | 824 | 14038.6 |
-| @tanstack/markdown parse | code-heavy.md | 1011 | 1000 | 0.0064 | 15 | -6268.1 |
-| @tanstack/markdown render AST with external highlighter | code-heavy.md | 1011 | 1000 | 0.0071 | 4504 | 347.4 |
-| @tanstack/markdown render AST | code-heavy.md | 1011 | 1000 | 0.0034 | 1835 | 12183.9 |
-| @tanstack/markdown parse+render with external highlighter | code-heavy.md | 1011 | 1000 | 0.0139 | 4504 | -5203.2 |
-| @tanstack/markdown parse+render | code-heavy.md | 1011 | 1000 | 0.0109 | 1835 | -26450.2 |
-| marked parse+render | code-heavy.md | 1011 | 1000 | 0.0059 | 1330 | 12896.3 |
-| markdown-it parse+render | code-heavy.md | 1011 | 1000 | 0.0088 | 1330 | -5356.3 |
-| micromark render | code-heavy.md | 1011 | 1000 | 0.1408 | 1330 | -11812.2 |
-| commonmark parse+render | code-heavy.md | 1011 | 1000 | 0.0094 | 1330 | -8107.4 |
-| markdown-wasm render | code-heavy.md | 1011 | 1000 | 0.0043 | 1512 | 2558.4 |
-| unified remark+rehype render | code-heavy.md | 1011 | 1000 | 0.1487 | 1200 | -6915.0 |
-| @tanstack/markdown parse | malformed.md | 237 | 2000 | 0.0023 | 15 | 18583.8 |
-| @tanstack/markdown render AST with external highlighter | malformed.md | 237 | 2000 | 0.0018 | 1067 | -16217.9 |
-| @tanstack/markdown render AST | malformed.md | 237 | 2000 | 0.0007 | 361 | 6298.9 |
-| @tanstack/markdown parse+render with external highlighter | malformed.md | 237 | 2000 | 0.0042 | 1067 | 2330.6 |
-| @tanstack/markdown parse+render | malformed.md | 237 | 2000 | 0.0031 | 361 | -7910.2 |
-| marked parse+render | malformed.md | 237 | 2000 | 0.0050 | 350 | -16034.5 |
-| markdown-it parse+render | malformed.md | 237 | 2000 | 0.0041 | 300 | 24762.2 |
-| micromark render | malformed.md | 237 | 2000 | 0.0455 | 300 | -24868.7 |
-| commonmark parse+render | malformed.md | 237 | 2000 | 0.0034 | 300 | 23073.3 |
-| markdown-wasm render | malformed.md | 237 | 2000 | 0.0018 | 408 | 2938.8 |
-| unified remark+rehype render | malformed.md | 237 | 2000 | 0.0494 | 297 | 1974.7 |
-| @tanstack/markdown parse | prose-heavy.md | 1700 | 1000 | 0.0131 | 15 | 15263.7 |
-| @tanstack/markdown render AST with external highlighter | prose-heavy.md | 1700 | 1000 | 0.0022 | 1903 | -20948.9 |
-| @tanstack/markdown render AST | prose-heavy.md | 1700 | 1000 | 0.0022 | 1903 | 11793.5 |
-| @tanstack/markdown parse+render with external highlighter | prose-heavy.md | 1700 | 1000 | 0.0161 | 1903 | -5901.6 |
-| @tanstack/markdown parse+render | prose-heavy.md | 1700 | 1000 | 0.0159 | 1903 | -5896.0 |
-| marked parse+render | prose-heavy.md | 1700 | 1000 | 0.0354 | 1860 | 6719.4 |
-| markdown-it parse+render | prose-heavy.md | 1700 | 1000 | 0.0171 | 1860 | 14302.9 |
-| micromark render | prose-heavy.md | 1700 | 1000 | 0.2280 | 1862 | 84947.0 |
-| commonmark parse+render | prose-heavy.md | 1700 | 1000 | 0.0122 | 1862 | -13384.6 |
-| markdown-wasm render | prose-heavy.md | 1700 | 1000 | 0.0061 | 2340 | 3368.2 |
-| unified remark+rehype render | prose-heavy.md | 1700 | 1000 | 0.2498 | 1859 | 50851.1 |
-| @tanstack/markdown parse | small-doc.md | 432 | 2000 | 0.0089 | 15 | 7608.5 |
-| @tanstack/markdown render AST with external highlighter | small-doc.md | 432 | 2000 | 0.0030 | 1328 | 30267.3 |
-| @tanstack/markdown render AST | small-doc.md | 432 | 2000 | 0.0026 | 1002 | -39909.2 |
-| @tanstack/markdown parse+render with external highlighter | small-doc.md | 432 | 2000 | 0.0121 | 1328 | 33858.0 |
-| @tanstack/markdown parse+render | small-doc.md | 432 | 2000 | 0.0118 | 1002 | -37329.9 |
-| marked parse+render | small-doc.md | 432 | 2000 | 0.0121 | 724 | -14790.5 |
-| markdown-it parse+render | small-doc.md | 432 | 2000 | 0.0075 | 776 | 3163.1 |
-| micromark render | small-doc.md | 432 | 2000 | 0.0964 | 535 | 45302.1 |
-| commonmark parse+render | small-doc.md | 432 | 2000 | 0.0065 | 535 | -15211.3 |
-| markdown-wasm render | small-doc.md | 432 | 2000 | 0.0033 | 854 | 3642.8 |
-| unified remark+rehype render | small-doc.md | 432 | 2000 | 0.1119 | 534 | -61627.6 |
-| @tanstack/markdown parse | tables-lists.md | 454 | 2000 | 0.0224 | 15 | 21773.1 |
-| @tanstack/markdown render AST with external highlighter | tables-lists.md | 454 | 2000 | 0.0043 | 1315 | -18808.4 |
-| @tanstack/markdown render AST | tables-lists.md | 454 | 2000 | 0.0038 | 1315 | 46672.4 |
-| @tanstack/markdown parse+render with external highlighter | tables-lists.md | 454 | 2000 | 0.0266 | 1315 | 2395.1 |
-| @tanstack/markdown parse+render | tables-lists.md | 454 | 2000 | 0.0264 | 1315 | 2248.8 |
-| marked parse+render | tables-lists.md | 454 | 2000 | 0.0278 | 1102 | -14314.8 |
-| markdown-it parse+render | tables-lists.md | 454 | 2000 | 0.0178 | 1325 | -39188.7 |
-| micromark render | tables-lists.md | 454 | 2000 | 0.1602 | 627 | 26000.9 |
-| commonmark parse+render | tables-lists.md | 454 | 2000 | 0.0111 | 627 | -37171.3 |
-| markdown-wasm render | tables-lists.md | 454 | 2000 | 0.0055 | 1202 | 4506.0 |
-| unified remark+rehype render | tables-lists.md | 454 | 2000 | 0.1868 | 622 | 7536.4 |
+| @tanstack/markdown parse | ai-response.md | 652 | 2000 | 0.0184 | 15 | 899.0 |
+| @tanstack/markdown render AST with external highlighter | ai-response.md | 652 | 2000 | 0.0053 | 1399 | 1238.7 |
+| @tanstack/markdown render AST | ai-response.md | 652 | 2000 | 0.0040 | 1143 | -3027.4 |
+| @tanstack/markdown parse+render with external highlighter | ai-response.md | 652 | 2000 | 0.0238 | 1399 | -2596.2 |
+| @tanstack/markdown parse+render | ai-response.md | 652 | 2000 | 0.0236 | 1143 | -1860.6 |
+| marked parse+render | ai-response.md | 652 | 2000 | 0.0295 | 1035 | -853.6 |
+| markdown-it parse+render | ai-response.md | 652 | 2000 | 0.0193 | 1087 | 4088.9 |
+| micromark render | ai-response.md | 652 | 2000 | 0.1731 | 825 | -8298.2 |
+| commonmark parse+render | ai-response.md | 652 | 2000 | 0.0213 | 825 | 54.9 |
+| markdown-wasm render | ai-response.md | 652 | 2000 | 0.0105 | 1117 | 4372.4 |
+| unified remark+rehype render | ai-response.md | 652 | 2000 | 0.1991 | 824 | 13756.0 |
+| @tanstack/markdown parse | code-heavy.md | 1011 | 1000 | 0.0068 | 15 | -6378.1 |
+| @tanstack/markdown render AST with external highlighter | code-heavy.md | 1011 | 1000 | 0.0075 | 4596 | -1122.6 |
+| @tanstack/markdown render AST | code-heavy.md | 1011 | 1000 | 0.0032 | 1927 | 10476.3 |
+| @tanstack/markdown parse+render with external highlighter | code-heavy.md | 1011 | 1000 | 0.0142 | 4596 | -6339.7 |
+| @tanstack/markdown parse+render | code-heavy.md | 1011 | 1000 | 0.0099 | 1927 | 4603.2 |
+| marked parse+render | code-heavy.md | 1011 | 1000 | 0.0062 | 1330 | -19757.1 |
+| markdown-it parse+render | code-heavy.md | 1011 | 1000 | 0.0083 | 1330 | -5321.0 |
+| micromark render | code-heavy.md | 1011 | 1000 | 0.1368 | 1330 | -11833.3 |
+| commonmark parse+render | code-heavy.md | 1011 | 1000 | 0.0092 | 1330 | 24577.0 |
+| markdown-wasm render | code-heavy.md | 1011 | 1000 | 0.0043 | 1512 | 2560.0 |
+| unified remark+rehype render | code-heavy.md | 1011 | 1000 | 0.1521 | 1200 | -7038.0 |
+| @tanstack/markdown parse | malformed.md | 237 | 2000 | 0.0027 | 15 | 18580.6 |
+| @tanstack/markdown render AST with external highlighter | malformed.md | 237 | 2000 | 0.0017 | 1067 | -17067.3 |
+| @tanstack/markdown render AST | malformed.md | 237 | 2000 | 0.0007 | 361 | 5314.9 |
+| @tanstack/markdown parse+render with external highlighter | malformed.md | 237 | 2000 | 0.0046 | 1067 | 1452.2 |
+| @tanstack/markdown parse+render | malformed.md | 237 | 2000 | 0.0036 | 361 | -8892.2 |
+| marked parse+render | malformed.md | 237 | 2000 | 0.0055 | 350 | 16140.5 |
+| markdown-it parse+render | malformed.md | 237 | 2000 | 0.0051 | 300 | -7253.6 |
+| micromark render | malformed.md | 237 | 2000 | 0.0521 | 300 | 7694.5 |
+| commonmark parse+render | malformed.md | 237 | 2000 | 0.0039 | 300 | -9655.9 |
+| markdown-wasm render | malformed.md | 237 | 2000 | 0.0019 | 408 | 2938.4 |
+| unified remark+rehype render | malformed.md | 237 | 2000 | 0.0692 | 297 | 2219.2 |
+| @tanstack/markdown parse | prose-heavy.md | 1700 | 1000 | 0.0172 | 15 | -17554.1 |
+| @tanstack/markdown render AST with external highlighter | prose-heavy.md | 1700 | 1000 | 0.0027 | 1903 | 10296.0 |
+| @tanstack/markdown render AST | prose-heavy.md | 1700 | 1000 | 0.0027 | 1903 | 10321.3 |
+| @tanstack/markdown parse+render with external highlighter | prose-heavy.md | 1700 | 1000 | 0.0177 | 1903 | -7355.0 |
+| @tanstack/markdown parse+render | prose-heavy.md | 1700 | 1000 | 0.0166 | 1903 | -7244.9 |
+| marked parse+render | prose-heavy.md | 1700 | 1000 | 0.0345 | 1860 | 6729.9 |
+| markdown-it parse+render | prose-heavy.md | 1700 | 1000 | 0.0189 | 1860 | -18343.2 |
+| micromark render | prose-heavy.md | 1700 | 1000 | 0.2429 | 1862 | 24865.8 |
+| commonmark parse+render | prose-heavy.md | 1700 | 1000 | 0.0119 | 1862 | 46458.7 |
+| markdown-wasm render | prose-heavy.md | 1700 | 1000 | 0.0063 | 2340 | 3368.4 |
+| unified remark+rehype render | prose-heavy.md | 1700 | 1000 | 0.2897 | 1859 | 50897.1 |
+| @tanstack/markdown parse | small-doc.md | 432 | 2000 | 0.0094 | 15 | 7577.4 |
+| @tanstack/markdown render AST with external highlighter | small-doc.md | 432 | 2000 | 0.0031 | 1370 | 27814.1 |
+| @tanstack/markdown render AST | small-doc.md | 432 | 2000 | 0.0027 | 1044 | -42766.9 |
+| @tanstack/markdown parse+render with external highlighter | small-doc.md | 432 | 2000 | 0.0126 | 1370 | 31414.9 |
+| @tanstack/markdown parse+render | small-doc.md | 432 | 2000 | 0.0121 | 1044 | -40133.3 |
+| marked parse+render | small-doc.md | 432 | 2000 | 0.0121 | 724 | 50632.2 |
+| markdown-it parse+render | small-doc.md | 432 | 2000 | 0.0078 | 776 | 3160.2 |
+| micromark render | small-doc.md | 432 | 2000 | 0.0974 | 535 | 44970.4 |
+| commonmark parse+render | small-doc.md | 432 | 2000 | 0.0071 | 535 | -15145.2 |
+| markdown-wasm render | small-doc.md | 432 | 2000 | 0.0039 | 854 | 3580.2 |
+| unified remark+rehype render | small-doc.md | 432 | 2000 | 0.1293 | 534 | 4179.8 |
+| @tanstack/markdown parse | tables-lists.md | 454 | 2000 | 0.0232 | 15 | -43744.4 |
+| @tanstack/markdown render AST with external highlighter | tables-lists.md | 454 | 2000 | 0.0044 | 1315 | 42278.0 |
+| @tanstack/markdown render AST | tables-lists.md | 454 | 2000 | 0.0040 | 1315 | -23161.2 |
+| @tanstack/markdown parse+render with external highlighter | tables-lists.md | 454 | 2000 | 0.0274 | 1315 | -1853.1 |
+| @tanstack/markdown parse+render | tables-lists.md | 454 | 2000 | 0.0272 | 1315 | -2112.7 |
+| marked parse+render | tables-lists.md | 454 | 2000 | 0.0292 | 1102 | -14329.2 |
+| markdown-it parse+render | tables-lists.md | 454 | 2000 | 0.0180 | 1325 | 26395.5 |
+| micromark render | tables-lists.md | 454 | 2000 | 0.1677 | 627 | -39153.3 |
+| commonmark parse+render | tables-lists.md | 454 | 2000 | 0.0114 | 627 | 28363.8 |
+| markdown-wasm render | tables-lists.md | 454 | 2000 | 0.0059 | 1202 | 4506.2 |
+| unified remark+rehype render | tables-lists.md | 454 | 2000 | 0.1964 | 622 | 7601.9 |
 
 ## Streaming
 
 | Name | Fixture | Bytes | Iterations | ms/op | Output bytes | Heap delta KB |
 | :--- | :--- | ---: | ---: | ---: | ---: | ---: |
-| @tanstack/markdown streaming profile | ai-response.md | 652 | 250 | 0.2125 | 1112 | 62452.2 |
-| marked progressive parse+render | ai-response.md | 652 | 250 | 0.2893 | 1035 | 3190.0 |
+| @tanstack/markdown streaming profile | ai-response.md | 652 | 250 | 0.2236 | 1112 | -8191.5 |
+| marked progressive parse+render | ai-response.md | 652 | 250 | 0.2804 | 1035 | 3232.0 |
 
 ## Averages
 
 | Group | Name | Mean ms/op |
 | :--- | :--- | ---: |
-| markdown | @tanstack/markdown parse | 0.0116 |
-| markdown | @tanstack/markdown render AST with external highlighter | 0.0037 |
-| markdown | @tanstack/markdown render AST | 0.0027 |
-| markdown | @tanstack/markdown parse+render with external highlighter | 0.0155 |
-| markdown | @tanstack/markdown parse+render | 0.0145 |
-| markdown | marked parse+render | 0.0187 |
-| markdown | markdown-it parse+render | 0.0121 |
-| markdown | micromark render | 0.1363 |
-| markdown | commonmark parse+render | 0.0091 |
-| markdown | markdown-wasm render | 0.0048 |
-| markdown | unified remark+rehype render | 0.1527 |
-| streaming | @tanstack/markdown streaming profile | 0.2125 |
-| streaming | marked progressive parse+render | 0.2893 |
+| markdown | @tanstack/markdown parse | 0.0129 |
+| markdown | @tanstack/markdown render AST with external highlighter | 0.0041 |
+| markdown | @tanstack/markdown render AST | 0.0029 |
+| markdown | @tanstack/markdown parse+render with external highlighter | 0.0167 |
+| markdown | @tanstack/markdown parse+render | 0.0155 |
+| markdown | marked parse+render | 0.0195 |
+| markdown | markdown-it parse+render | 0.0129 |
+| markdown | micromark render | 0.1450 |
+| markdown | commonmark parse+render | 0.0108 |
+| markdown | markdown-wasm render | 0.0055 |
+| markdown | unified remark+rehype render | 0.1726 |
+| streaming | @tanstack/markdown streaming profile | 0.2236 |
+| streaming | marked progressive parse+render | 0.2804 |
diff --git a/reports/conformance.json b/reports/conformance.json
index 1ee38fa..4b4dee6 100644
--- a/reports/conformance.json
+++ b/reports/conformance.json
@@ -1,5 +1,5 @@
 {
-  "generatedAt": "2026-09-11T18:44:25.803Z",
+  "generatedAt": "2026-09-11T20:23:24.612Z",
   "commonMarkVersion": "0.31.2",
   "baseline": 403,
   "lost": [],
diff --git a/reports/conformance.md b/reports/conformance.md
index 6ef69ce..b84d0cd 100644
--- a/reports/conformance.md
+++ b/reports/conformance.md
@@ -1,6 +1,6 @@
 # Conformance Report
 
-Generated: 2026-09-11T18:44:25.804Z
+Generated: 2026-09-11T20:23:24.613Z
 
 This is compatibility accounting against CommonMark 0.31.2, not a claim of CommonMark conformance.
 
diff --git a/reports/external-corpus.json b/reports/external-corpus.json
index cbfa23b..187748e 100644
--- a/reports/external-corpus.json
+++ b/reports/external-corpus.json
@@ -1,8 +1,8 @@
 {
-  "generatedAt": "2026-09-11T18:23:48.314Z",
+  "generatedAt": "2026-09-11T20:21:53.280Z",
   "name": "Representative external docs and blogs corpus",
   "roots": 20,
-  "durationMs": 15875.995333,
+  "durationMs": 15746.938083000001,
   "totals": {
     "name": "Representative external docs and blogs corpus",
     "bytes": 87469380,
@@ -13,8 +13,8 @@
     "comparisons": {
       "exact": 5384,
       "serialization": 626,
-      "structure": 1876,
-      "content": 2631
+      "structure": 1862,
+      "content": 2645
     },
     "features": {
       "atx-heading": 7353,
@@ -123,8 +123,8 @@
       "comparisons": {
         "exact": 150,
         "serialization": 12,
-        "structure": 46,
-        "content": 19
+        "structure": 45,
+        "content": 20
       },
       "features": {
         "atx-heading": 218,
@@ -161,8 +161,8 @@
       "comparisons": {
         "exact": 29,
         "serialization": 11,
-        "structure": 68,
-        "content": 9
+        "structure": 67,
+        "content": 10
       },
       "features": {
         "atx-heading": 117,
@@ -330,8 +330,8 @@
       "comparisons": {
         "exact": 2080,
         "serialization": 72,
-        "structure": 245,
-        "content": 1326
+        "structure": 242,
+        "content": 1329
       },
       "features": {
         "atx-heading": 2737,
@@ -403,8 +403,8 @@
       "comparisons": {
         "exact": 1280,
         "serialization": 238,
-        "structure": 729,
-        "content": 183
+        "structure": 728,
+        "content": 184
       },
       "features": {
         "atx-heading": 1526,
@@ -622,8 +622,8 @@
       "comparisons": {
         "exact": 632,
         "serialization": 82,
-        "structure": 69,
-        "content": 210
+        "structure": 71,
+        "content": 208
       },
       "features": {
         "atx-heading": 309,
@@ -658,8 +658,8 @@
       "comparisons": {
         "exact": 115,
         "serialization": 119,
-        "structure": 346,
-        "content": 163
+        "structure": 336,
+        "content": 173
       },
       "features": {
         "atx-heading": 547,
@@ -752,7 +752,7 @@
         "actual": "...pression matching. Popular Envoy-based Gateway API implementations such as Istio1, Envoy Gateway, and Kgateway do a full case-sensitive match. Thus, if you are unaware that Ingress-NGINX patterns are prefix and case-insensitive, and, unbekn...",
         "reference": "...pression matching. Popular Envoy-based Gateway API implementations such as Istio[^1], Envoy Gateway, and Kgateway do a full case-sensitive match. Thus, if you are unaware that Ingress-NGINX patterns are prefix and case-insensitive, and, unb..."
       },
-      "durationMs": 2.916333000001032
+      "durationMs": 2.1839160000017728
     },
     {
       "repository": "rust-blog",
@@ -771,7 +771,7 @@
         "actual": "... fixes have been made since the original post, the text is otherwise unaltered. 1 As is noted in the summary, the next steps are to take the findings we have so far and begin crafting proposals for how the Rust project will be governed in t...",
         "reference": "... fixes have been made since the original post, the text is otherwise unaltered. [^1] As is noted in the summary, the next steps are to take the findings we have so far and begin crafting proposals for how the Rust project will be governed i..."
       },
-      "durationMs": 2.4650000000001455
+      "durationMs": 2.265292000000045
     },
     {
       "repository": "overreacted",
@@ -788,7 +788,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 9.637832999999773
+      "durationMs": 9.801625000000058
     },
     {
       "repository": "github-docs",
@@ -802,7 +802,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 3.5552920000000086
+      "durationMs": 3.7559169999994992
     },
     {
       "repository": "deno-docs",
@@ -818,7 +818,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 6.1148749999993015
+      "durationMs": 6.060667000001558
     },
     {
       "repository": "kubernetes-docs",
@@ -833,7 +833,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 6.132708000000093
+      "durationMs": 6.70795900000121
     },
     {
       "repository": "typescript-docs",
@@ -847,7 +847,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 3.9006250000002183
+      "durationMs": 3.8744169999990845
     },
     {
       "repository": "overreacted",
@@ -862,7 +862,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 3.1699579999999514
+      "durationMs": 3.3919160000000375
     },
     {
       "repository": "docker-docs",
@@ -877,7 +877,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 7.430958999999348
+      "durationMs": 5.921625000000859
     },
     {
       "repository": "kubernetes-docs",
@@ -891,7 +891,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 3.8100000000013097
+      "durationMs": 3.5147909999996045
     },
     {
       "repository": "kubernetes-docs",
@@ -905,7 +905,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 3.2704160000002958
+      "durationMs": 3.1285420000003796
     },
     {
       "repository": "github-docs",
@@ -918,7 +918,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 2.660041000000092
+      "durationMs": 3.522917000000234
     },
     {
       "repository": "kubernetes-docs",
@@ -935,7 +935,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 2.688415999999961
+      "durationMs": 2.7179159999996045
     },
     {
       "repository": "overreacted",
@@ -950,7 +950,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 3.338999999999942
+      "durationMs": 3.0559579999999187
     },
     {
       "repository": "docker-docs",
@@ -966,7 +966,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 5.433875000000626
+      "durationMs": 6.412417000001369
     },
     {
       "repository": "pnpm-site",
@@ -984,7 +984,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 5.317375000000538
+      "durationMs": 3.219375000000582
     },
     {
       "repository": "typescript-docs",
@@ -998,7 +998,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 3.043875000001208
+      "durationMs": 2.977499999999054
     },
     {
       "repository": "deno-docs",
@@ -1012,7 +1012,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 2.9512500000000728
+      "durationMs": 2.974291999998968
     },
     {
       "repository": "deno-docs",
@@ -1027,7 +1027,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 2.506875000000946
+      "durationMs": 2.348834000000352
     },
     {
       "repository": "vite",
@@ -1042,7 +1042,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 3.6188749999999743
+      "durationMs": 3.6632919999999842
     },
     {
       "repository": "overreacted",
@@ -1057,7 +1057,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 2.6689590000000862
+      "durationMs": 2.8792080000002898
     },
     {
       "repository": "deno-docs",
@@ -1072,7 +1072,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 2.8883330000007845
+      "durationMs": 3.373874999999316
     },
     {
       "repository": "kubernetes-docs",
@@ -1085,7 +1085,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.6490420000000086
+      "durationMs": 1.6709579999997004
     },
     {
       "repository": "kubernetes-docs",
@@ -1098,7 +1098,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 3.1517499999999927
+      "durationMs": 2.0188340000004246
     },
     {
       "repository": "deno-docs",
@@ -1111,7 +1111,7 @@
         "hard-break"
       ],
       "comparison": "structure",
-      "durationMs": 2.0752080000002024
+      "durationMs": 2.0488749999985885
     },
     {
       "repository": "deno-docs",
@@ -1127,7 +1127,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 2.9173329999994166
+      "durationMs": 2.1662909999995463
     },
     {
       "repository": "kubernetes-docs",
@@ -1141,7 +1141,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 2.2432920000001104
+      "durationMs": 2.0053329999991547
     },
     {
       "repository": "kubernetes-docs",
@@ -1156,7 +1156,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.8175419999988662
+      "durationMs": 2.320958000000246
     },
     {
       "repository": "kubernetes-docs",
@@ -1171,7 +1171,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.9119170000003578
+      "durationMs": 2.0090000000000146
     },
     {
       "repository": "docker-docs",
@@ -1185,7 +1185,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.639791999999943
+      "durationMs": 1.6659169999984442
     },
     {
       "repository": "rust-blog",
@@ -1198,7 +1198,7 @@
         "fenced-code"
       ],
       "comparison": "structure",
-      "durationMs": 2.6714580000007118
+      "durationMs": 2.4457500000007713
     },
     {
       "repository": "rust-blog",
@@ -1212,7 +1212,7 @@
         "reference-link"
       ],
       "comparison": "structure",
-      "durationMs": 3.0761249999995925
+      "durationMs": 1.6947090000012395
     },
     {
       "repository": "kubernetes-docs",
@@ -1225,7 +1225,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.5136249999995925
+      "durationMs": 1.5562079999999696
     },
     {
       "repository": "github-docs",
@@ -1238,7 +1238,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.147791999999754
+      "durationMs": 1.2310829999996713
     },
     {
       "repository": "kubernetes-docs",
@@ -1252,7 +1252,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 2.840458000000581
+      "durationMs": 1.3239169999997102
     },
     {
       "repository": "deno-docs",
@@ -1267,7 +1267,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 2.4970419999990554
+      "durationMs": 2.131207999998878
     },
     {
       "repository": "kubernetes-docs",
@@ -1280,7 +1280,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.5909590000001117
+      "durationMs": 1.5817919999999503
     },
     {
       "repository": "overreacted",
@@ -1295,7 +1295,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 2.5851659999998446
+      "durationMs": 2.3064580000000205
     },
     {
       "repository": "rust-blog",
@@ -1308,7 +1308,7 @@
         "blockquote"
       ],
       "comparison": "structure",
-      "durationMs": 3.459416000001511
+      "durationMs": 2.3713749999988067
     },
     {
       "repository": "kubernetes-docs",
@@ -1321,7 +1321,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.680000000000291
+      "durationMs": 1.7698749999999563
     },
     {
       "repository": "kubernetes-docs",
@@ -1337,7 +1337,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.9991250000002765
+      "durationMs": 1.840208999999959
     },
     {
       "repository": "kubernetes-docs",
@@ -1350,7 +1350,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.6140000000004875
+      "durationMs": 2.2755830000005517
     },
     {
       "repository": "kubernetes-docs",
@@ -1365,7 +1365,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 1.2834590000002208
+      "durationMs": 1.7618750000001455
     },
     {
       "repository": "kubernetes-docs",
@@ -1378,7 +1378,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.545124999998734
+      "durationMs": 1.7177920000012818
     },
     {
       "repository": "deno-docs",
@@ -1395,7 +1395,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 2.4031660000000556
+      "durationMs": 2.628292000001238
     },
     {
       "repository": "deno-docs",
@@ -1410,7 +1410,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 2.9570000000003347
+      "durationMs": 1.5436250000002474
     },
     {
       "repository": "kubernetes-docs",
@@ -1424,7 +1424,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.7811670000000959
+      "durationMs": 1.2055419999996957
     },
     {
       "repository": "kubernetes-docs",
@@ -1438,7 +1438,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 2.119666999999936
+      "durationMs": 1.5015839999996388
     },
     {
       "repository": "kubernetes-docs",
@@ -1453,7 +1453,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.4283329999998386
+      "durationMs": 1.5232090000008611
     },
     {
       "repository": "github-docs",
@@ -1467,7 +1467,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.5787499999996726
+      "durationMs": 1.6257080000004862
     },
     {
       "repository": "kubernetes-docs",
@@ -1480,7 +1480,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.5308749999994689
+      "durationMs": 1.9289159999998446
     },
     {
       "repository": "rust-blog",
@@ -1493,7 +1493,7 @@
         "blockquote"
       ],
       "comparison": "structure",
-      "durationMs": 1.8179159999999683
+      "durationMs": 1.5679999999993015
     },
     {
       "repository": "kubernetes-docs",
@@ -1507,7 +1507,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.6083749999997963
+      "durationMs": 1.6330409999991389
     },
     {
       "repository": "kubernetes-docs",
@@ -1520,7 +1520,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.2049580000002607
+      "durationMs": 1.5780000000004293
     },
     {
       "repository": "kubernetes-docs",
@@ -1534,7 +1534,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.6752500000002328
+      "durationMs": 1.2433750000000146
     },
     {
       "repository": "kubernetes-docs",
@@ -1549,7 +1549,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.8719579999997222
+      "durationMs": 1.8465829999986454
     },
     {
       "repository": "kubernetes-docs",
@@ -1562,7 +1562,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.7269589999996242
+      "durationMs": 1.567709000000832
     },
     {
       "repository": "kubernetes-docs",
@@ -1575,7 +1575,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.5285830000011629
+      "durationMs": 1.556416999999783
     },
     {
       "repository": "kubernetes-docs",
@@ -1589,7 +1589,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.079874999999447
+      "durationMs": 1.5011670000003505
     },
     {
       "repository": "kubernetes-docs",
@@ -1603,7 +1603,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.3955839999998716
+      "durationMs": 1.3984590000000026
     },
     {
       "repository": "svelte-dev",
@@ -1616,7 +1616,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 2.1256250000001273
+      "durationMs": 2.1298329999999623
     },
     {
       "repository": "kubernetes-docs",
@@ -1631,7 +1631,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.4412090000005264
+      "durationMs": 1.366875000001528
     },
     {
       "repository": "kubernetes-docs",
@@ -1645,7 +1645,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.080207999999402
+      "durationMs": 1.191916000000674
     },
     {
       "repository": "kubernetes-docs",
@@ -1658,7 +1658,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.0621250000003783
+      "durationMs": 1.3120410000010452
     },
     {
       "repository": "svelte-dev",
@@ -1671,7 +1671,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 2.108165999999983
+      "durationMs": 1.9855000000002292
     },
     {
       "repository": "kubernetes-docs",
@@ -1685,7 +1685,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.2033750000009604
+      "durationMs": 1.300708000000668
     },
     {
       "repository": "kubernetes-docs",
@@ -1700,7 +1700,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 1.5403749999995853
+      "durationMs": 1.7719999999999345
     },
     {
       "repository": "kubernetes-docs",
@@ -1715,7 +1715,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.6869580000002316
+      "durationMs": 1.4317919999994047
     },
     {
       "repository": "svelte-dev",
@@ -1729,7 +1729,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 2.195417000000134
+      "durationMs": 2.057459000000108
     },
     {
       "repository": "deno-docs",
@@ -1744,7 +1744,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.2545829999999114
+      "durationMs": 1.3593330000003334
     },
     {
       "repository": "deno-docs",
@@ -1759,7 +1759,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 3.1370420000002923
+      "durationMs": 1.8297079999993002
     },
     {
       "repository": "svelte-dev",
@@ -1773,7 +1773,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 2.0390420000001086
+      "durationMs": 2.0845409999997173
     },
     {
       "repository": "kubernetes-docs",
@@ -1787,7 +1787,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 0.906290999999328
+      "durationMs": 1.0732909999987896
     },
     {
       "repository": "svelte-dev",
@@ -1801,7 +1801,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 2.5510830000000624
+      "durationMs": 2.0117919999997866
     },
     {
       "repository": "kubernetes-docs",
@@ -1816,7 +1816,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.345166999999492
+      "durationMs": 1.3830419999994774
     },
     {
       "repository": "kubernetes-docs",
@@ -1829,7 +1829,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 0.9491250000000946
+      "durationMs": 0.9893339999998716
     },
     {
       "repository": "deno-docs",
@@ -1845,7 +1845,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 2.626333000000159
+      "durationMs": 1.421583000001192
     },
     {
       "repository": "svelte-dev",
@@ -1858,7 +1858,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.8280830000003334
+      "durationMs": 2.0852500000000873
     },
     {
       "repository": "svelte-dev",
@@ -1872,7 +1872,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.9007500000000164
+      "durationMs": 1.9899169999998776
     },
     {
       "repository": "kubernetes-docs",
@@ -1887,7 +1887,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 1.2182920000004742
+      "durationMs": 1.1084589999991294
     },
     {
       "repository": "svelte-dev",
@@ -1901,7 +1901,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.7927499999998417
+      "durationMs": 1.9809170000003178
     },
     {
       "repository": "svelte-dev",
@@ -1915,7 +1915,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.9733329999999114
+      "durationMs": 2.385041999999885
     },
     {
       "repository": "docker-docs",
@@ -1930,7 +1930,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.8537080000005517
+      "durationMs": 1.9487499999995634
     },
     {
       "repository": "kubernetes-docs",
@@ -1943,7 +1943,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 0.9855829999996786
+      "durationMs": 0.9864580000012211
     },
     {
       "repository": "docker-docs",
@@ -1957,7 +1957,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.3032920000005106
+      "durationMs": 1.259250000000975
     },
     {
       "repository": "kubernetes-docs",
@@ -1971,7 +1971,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.1472080000003189
+      "durationMs": 1.1858329999995476
     },
     {
       "repository": "svelte-dev",
@@ -1985,7 +1985,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.7943330000000515
+      "durationMs": 2.351166999999805
     },
     {
       "repository": "deno-docs",
@@ -2000,7 +2000,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.652249999999185
+      "durationMs": 1.6041670000013255
     },
     {
       "repository": "kubernetes-docs",
@@ -2014,7 +2014,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 0.9461249999994834
+      "durationMs": 0.9607089999990421
     },
     {
       "repository": "svelte-dev",
@@ -2028,7 +2028,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.8976669999999558
+      "durationMs": 1.8322080000002643
     },
     {
       "repository": "kubernetes-docs",
@@ -2041,7 +2041,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.4656670000003942
+      "durationMs": 1.217624999999316
     },
     {
       "repository": "docker-docs",
@@ -2056,7 +2056,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.4337500000001455
+      "durationMs": 1.5431249999983265
     },
     {
       "repository": "kubernetes-docs",
@@ -2070,7 +2070,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.0808749999996508
+      "durationMs": 1.1445420000000013
     },
     {
       "repository": "kubernetes-docs",
@@ -2083,7 +2083,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.2083339999999225
+      "durationMs": 1.181166999999732
     },
     {
       "repository": "kubernetes-docs",
@@ -2097,7 +2097,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.765082999999322
+      "durationMs": 1.5068749999991269
     },
     {
       "repository": "deno-docs",
@@ -2113,7 +2113,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 1.9496669999989535
+      "durationMs": 1.4440420000009908
     },
     {
       "repository": "kubernetes-docs",
@@ -2128,7 +2128,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.50091699999939
+      "durationMs": 1.566208000000188
     },
     {
       "repository": "deno-docs",
@@ -2144,7 +2144,7 @@
         "table"
       ],
       "comparison": "structure",
-      "durationMs": 2.116582999999082
+      "durationMs": 1.2599999999983993
     },
     {
       "repository": "deno-docs",
@@ -2159,7 +2159,7 @@
         "frontmatter"
       ],
       "comparison": "structure",
-      "durationMs": 1.528041999999914
+      "durationMs": 1.4219579999989946
     },
     {
       "repository": "deno-docs",
@@ -2174,7 +2174,7 @@
         "nested-list"
       ],
       "comparison": "structure",
-      "durationMs": 1.7729589999999007
+      "durationMs": 1.563916999999492
     }
   ]
 }
diff --git a/reports/external-corpus.md b/reports/external-corpus.md
index 8026979..82e2437 100644
--- a/reports/external-corpus.md
+++ b/reports/external-corpus.md
@@ -1,6 +1,6 @@
 # Representative external docs and blogs corpus
 
-Generated: 2026-09-11T18:23:48.314Z
+Generated: 2026-09-11T20:21:53.280Z
 
 This report measures practical corpus behavior. Marked is a differential reference, not a correctness oracle. MDX is inventoried but not parsed.
 
@@ -11,7 +11,7 @@ This report measures practical corpus behavior. Marked is a differential referen
 - parse or determinism errors: 0
 - content differences using only profile syntax: 2
 - unexplained target docs/blog/README content differences using only profile syntax: 0
-- audit time: 15876.0 ms
+- audit time: 15746.9 ms
 
 ## Output comparison
 
@@ -19,8 +19,8 @@ This report measures practical corpus behavior. Marked is a differential referen
 | :--- | ---: | ---: | :--- |
 | exact | 5384 | 51.2% | Normalized HTML matches Marked |
 | serialization | 626 | 6.0% | Tag and text shape matches; attributes or serialization differ |
-| structure | 1876 | 17.8% | Text matches; element structure differs |
-| content | 2631 | 25.0% | Rendered text differs and requires triage |
+| structure | 1862 | 17.7% | Text matches; element structure differs |
+| content | 2645 | 25.1% | Rendered text differs and requires triage |
 
 ## Repositories
 
@@ -28,23 +28,23 @@ This report measures practical corpus behavior. Marked is a differential referen
 | :--- | :--- | :---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
 | [vite](https://github.com/vitejs/vite) | [428bda3b](https://github.com/vitejs/vite/tree/428bda3b98886708006f4f2f409095f89d705df1) | no | 59 | 0 | 39 | 5 | 11 | 4 | 0 |
 | [vue-docs](https://github.com/vuejs/docs) | [01e098a9](https://github.com/vuejs/docs/tree/01e098a967951ea92d09b100b8e7d82c7a3369a2) | no | 120 | 0 | 75 | 20 | 19 | 6 | 0 |
-| [react-dev](https://github.com/reactjs/react.dev) | [7b6c3ceb](https://github.com/reactjs/react.dev/tree/7b6c3ceb9dd97249e9dce4a8a94e61aed6424698) | no | 227 | 0 | 150 | 12 | 46 | 19 | 0 |
-| [rust-book](https://github.com/rust-lang/book) | [91754488](https://github.com/rust-lang/book/tree/917544888a55e4da7109bdba8c88c893c0da70f4) | no | 117 | 0 | 29 | 11 | 68 | 9 | 0 |
+| [react-dev](https://github.com/reactjs/react.dev) | [7b6c3ceb](https://github.com/reactjs/react.dev/tree/7b6c3ceb9dd97249e9dce4a8a94e61aed6424698) | no | 227 | 0 | 150 | 12 | 45 | 20 | 0 |
+| [rust-book](https://github.com/rust-lang/book) | [91754488](https://github.com/rust-lang/book/tree/917544888a55e4da7109bdba8c88c893c0da70f4) | no | 117 | 0 | 29 | 11 | 67 | 10 | 0 |
 | [docusaurus](https://github.com/facebook/docusaurus) | [a0bc3221](https://github.com/facebook/docusaurus/tree/a0bc32214436d52a5ac9de9be1a515d872987366) | no | 7 | 123 | 2 | 0 | 4 | 1 | 0 |
 | [astro-docs](https://github.com/withastro/docs) | [c316ac57](https://github.com/withastro/docs/tree/c316ac570277bd5abd36a237c97adad8cede0c1b) | no | 2 | 417 | 0 | 0 | 2 | 0 | 0 |
 | [tailwind-site](https://github.com/tailwindlabs/tailwindcss.com) | [1e700c43](https://github.com/tailwindlabs/tailwindcss.com/tree/1e700c43f5f270a1a55c4a33e71f01952f24b8c2) | no | 1 | 262 | 1 | 0 | 0 | 0 | 0 |
 | [svelte-dev](https://github.com/sveltejs/svelte.dev) | [2f9fc2a2](https://github.com/sveltejs/svelte.dev/tree/2f9fc2a2431dd74b25eb9b6ae5d9051b45635f57) | no | 349 | 0 | 187 | 19 | 50 | 93 | 0 |
 | [overreacted](https://github.com/gaearon/overreacted.io) | [ac1ad4fe](https://github.com/gaearon/overreacted.io/tree/ac1ad4fe9168b350d3dc59ac0cbe0d53405702bf) | no | 59 | 0 | 42 | 1 | 11 | 5 | 0 |
-| [github-docs](https://github.com/github/docs) | [f455adb9](https://github.com/github/docs/tree/f455adb9ecb48ec7212177af292758bb2f32c1d7) | no | 3723 | 0 | 2080 | 72 | 245 | 1326 | 0 |
+| [github-docs](https://github.com/github/docs) | [f455adb9](https://github.com/github/docs/tree/f455adb9ecb48ec7212177af292758bb2f32c1d7) | no | 3723 | 0 | 2080 | 72 | 242 | 1329 | 0 |
 | [node-docs](https://github.com/nodejs/node) | [2deb0a1b](https://github.com/nodejs/node/tree/2deb0a1b0f542c4b7fedc16dda1149c65e546f0d) | no | 80 | 0 | 16 | 0 | 47 | 17 | 0 |
-| [kubernetes-docs](https://github.com/kubernetes/website) | [f2987ba1](https://github.com/kubernetes/website/tree/f2987ba1cceaa85fcd44cd1a221010d745d7335c) | no | 2430 | 0 | 1280 | 238 | 729 | 183 | 0 |
+| [kubernetes-docs](https://github.com/kubernetes/website) | [f2987ba1](https://github.com/kubernetes/website/tree/f2987ba1cceaa85fcd44cd1a221010d745d7335c) | no | 2430 | 0 | 1280 | 238 | 728 | 184 | 0 |
 | [docker-docs](https://github.com/docker/docs) | [d19a384f](https://github.com/docker/docs/tree/d19a384f8dd3b16ab3a4b5f3885b398e76f8638c) | no | 754 | 0 | 273 | 25 | 71 | 385 | 0 |
 | [laravel-docs](https://github.com/laravel/docs) | [ce4a1bf0](https://github.com/laravel/docs/tree/ce4a1bf093c2c09e3a029090136d6bea88b07d48) | no | 104 | 0 | 2 | 1 | 0 | 101 | 0 |
 | [typescript-docs](https://github.com/microsoft/TypeScript-Website) | [c8170c35](https://github.com/microsoft/TypeScript-Website/tree/c8170c35bda4811c9516cbb69c39241ae4beb6d9) | no | 135 | 0 | 97 | 13 | 18 | 7 | 0 |
 | [pnpm-site](https://github.com/pnpm/pnpm.io) | [ff58b983](https://github.com/pnpm/pnpm.io/tree/ff58b98362334807536e91e38ed66d0f14a87576) | no | 162 | 9 | 87 | 5 | 25 | 45 | 0 |
 | [deno-docs](https://github.com/denoland/docs) | [bcefb75f](https://github.com/denoland/docs/tree/bcefb75f3e49e19af0ac9d79b436df346a3b477b) | no | 361 | 2 | 245 | 3 | 107 | 6 | 0 |
-| [hugo-docs](https://github.com/gohugoio/hugoDocs) | [620696ab](https://github.com/gohugoio/hugoDocs/tree/620696ab3b07f66262e860e84b3793946d1660bc) | no | 993 | 0 | 632 | 82 | 69 | 210 | 0 |
-| [rust-blog](https://github.com/rust-lang/blog.rust-lang.org) | [f35e1174](https://github.com/rust-lang/blog.rust-lang.org/tree/f35e1174d2c15be7b692863b874a8a0046722081) | no | 743 | 0 | 115 | 119 | 346 | 163 | 0 |
+| [hugo-docs](https://github.com/gohugoio/hugoDocs) | [620696ab](https://github.com/gohugoio/hugoDocs/tree/620696ab3b07f66262e860e84b3793946d1660bc) | no | 993 | 0 | 632 | 82 | 71 | 208 | 0 |
+| [rust-blog](https://github.com/rust-lang/blog.rust-lang.org) | [f35e1174](https://github.com/rust-lang/blog.rust-lang.org/tree/f35e1174d2c15be7b692863b874a8a0046722081) | no | 743 | 0 | 115 | 119 | 336 | 173 | 0 |
 | [mdn-guides](https://github.com/mdn/content) | [3b763f8f](https://github.com/mdn/content/tree/3b763f8f076c053b7a44e261c3a19a1879bc11ff) | no | 91 | 0 | 32 | 0 | 8 | 51 | 0 |
 
 ## Content kinds
diff --git a/reports/sizes.json b/reports/sizes.json
index 213f8af..5347214 100644
--- a/reports/sizes.json
+++ b/reports/sizes.json
@@ -1,40 +1,40 @@
 {
-  "generatedAt": "2026-09-11T18:44:26.815Z",
+  "generatedAt": "2026-09-11T20:23:25.725Z",
   "results": [
     {
       "group": "tanstack",
       "name": "parser only",
-      "minBytes": 13084,
-      "gzipBytes": 4941,
-      "brotliBytes": 4555
+      "minBytes": 13157,
+      "gzipBytes": 4975,
+      "brotliBytes": 4593
     },
     {
       "group": "tanstack",
       "name": "html renderer no highlighter",
-      "minBytes": 18066,
-      "gzipBytes": 6730,
-      "brotliBytes": 6161
+      "minBytes": 18337,
+      "gzipBytes": 6807,
+      "brotliBytes": 6240
     },
     {
       "group": "tanstack",
       "name": "html renderer with external highlighter stub",
-      "minBytes": 18102,
-      "gzipBytes": 6751,
-      "brotliBytes": 6179
+      "minBytes": 18373,
+      "gzipBytes": 6829,
+      "brotliBytes": 6260
     },
     {
       "group": "tanstack",
       "name": "react adapter",
-      "minBytes": 18030,
-      "gzipBytes": 6648,
-      "brotliBytes": 6107
+      "minBytes": 18271,
+      "gzipBytes": 6722,
+      "brotliBytes": 6184
     },
     {
       "group": "tanstack",
       "name": "octane adapter",
-      "minBytes": 18033,
-      "gzipBytes": 6649,
-      "brotliBytes": 6108
+      "minBytes": 18283,
+      "gzipBytes": 6727,
+      "brotliBytes": 6194
     },
     {
       "group": "tanstack",
@@ -60,9 +60,9 @@
     {
       "group": "tanstack",
       "name": "react adapter with streaming extension",
-      "minBytes": 18721,
-      "gzipBytes": 6835,
-      "brotliBytes": 6282
+      "minBytes": 18962,
+      "gzipBytes": 6907,
+      "brotliBytes": 6356
     },
     {
       "group": "tanstack",
@@ -116,37 +116,37 @@
     {
       "group": "tanstack-public",
       "name": ".",
-      "minBytes": 18328,
-      "gzipBytes": 6853,
-      "brotliBytes": 6262
+      "minBytes": 18598,
+      "gzipBytes": 6936,
+      "brotliBytes": 6345
     },
     {
       "group": "tanstack-public",
       "name": "./html",
-      "minBytes": 18289,
-      "gzipBytes": 6841,
-      "brotliBytes": 6258
+      "minBytes": 18560,
+      "gzipBytes": 6920,
+      "brotliBytes": 6331
     },
     {
       "group": "tanstack-public",
       "name": "./parser",
-      "minBytes": 13216,
-      "gzipBytes": 5027,
-      "brotliBytes": 4630
+      "minBytes": 13289,
+      "gzipBytes": 5061,
+      "brotliBytes": 4664
     },
     {
       "group": "tanstack-public",
       "name": "./react",
-      "minBytes": 18229,
-      "gzipBytes": 6756,
-      "brotliBytes": 6200
+      "minBytes": 18470,
+      "gzipBytes": 6828,
+      "brotliBytes": 6279
     },
     {
       "group": "tanstack-public",
       "name": "./octane",
-      "minBytes": 18235,
-      "gzipBytes": 6756,
-      "brotliBytes": 6207
+      "minBytes": 18485,
+      "gzipBytes": 6833,
+      "brotliBytes": 6287
     },
     {
       "group": "tanstack-public",
diff --git a/reports/sizes.md b/reports/sizes.md
index f9c8e9c..a30d23f 100644
--- a/reports/sizes.md
+++ b/reports/sizes.md
@@ -1,20 +1,20 @@
 # Bundle Size Results
 
-Generated: 2026-09-11T18:44:26.816Z
+Generated: 2026-09-11T20:23:25.726Z
 
 Bundles are ESM, browser-targeted, minified with esbuild, then gzip and brotli compressed. Framework runtimes are externalized for the React and Octane adapters.
 
 | Group | Entry | Min bytes | Gzip bytes | Brotli bytes |
 | :--- | :--- | ---: | ---: | ---: |
-| tanstack | parser only | 13084 | 4941 | 4555 |
-| tanstack | html renderer no highlighter | 18066 | 6730 | 6161 |
-| tanstack | html renderer with external highlighter stub | 18102 | 6751 | 6179 |
-| tanstack | react adapter | 18030 | 6648 | 6107 |
-| tanstack | octane adapter | 18033 | 6649 | 6108 |
+| tanstack | parser only | 13157 | 4975 | 4593 |
+| tanstack | html renderer no highlighter | 18337 | 6807 | 6240 |
+| tanstack | html renderer with external highlighter stub | 18373 | 6829 | 6260 |
+| tanstack | react adapter | 18271 | 6722 | 6184 |
+| tanstack | octane adapter | 18283 | 6727 | 6194 |
 | tanstack | docs extension preset | 6423 | 2292 | 2073 |
 | tanstack | callouts extension | 506 | 335 | 278 |
 | tanstack | streaming extension | 699 | 311 | 253 |
-| tanstack | react adapter with streaming extension | 18721 | 6835 | 6282 |
+| tanstack | react adapter with streaming extension | 18962 | 6907 | 6356 |
 | tanstack | tabs transforms | 3290 | 1221 | 1082 |
 | markdown | marked | 41415 | 12548 | 11509 |
 | markdown | markdown-it | 148242 | 52655 | 44023 |
@@ -22,11 +22,11 @@ Bundles are ESM, browser-targeted, minified with esbuild, then gzip and brotli c
 | markdown | commonmark | 159687 | 48084 | 39793 |
 | markdown | markdown-wasm browser js+wasm | 66387 | 31275 | 26431 |
 | markdown | unified remark+rehype | 119588 | 36843 | 32686 |
-| tanstack-public | . | 18328 | 6853 | 6262 |
-| tanstack-public | ./html | 18289 | 6841 | 6258 |
-| tanstack-public | ./parser | 13216 | 5027 | 4630 |
-| tanstack-public | ./react | 18229 | 6756 | 6200 |
-| tanstack-public | ./octane | 18235 | 6756 | 6207 |
+| tanstack-public | . | 18598 | 6936 | 6345 |
+| tanstack-public | ./html | 18560 | 6920 | 6331 |
+| tanstack-public | ./parser | 13289 | 5061 | 4664 |
+| tanstack-public | ./react | 18470 | 6828 | 6279 |
+| tanstack-public | ./octane | 18485 | 6833 | 6287 |
 | tanstack-public | ./extensions/callouts | 660 | 432 | 360 |
 | tanstack-public | ./extensions/comment-components | 1073 | 647 | 542 |
 | tanstack-public | ./extensions/docs | 6587 | 2392 | 2162 |
diff --git a/scripts/audit-corpus.ts b/scripts/audit-corpus.ts
index 8003a37..30d21f0 100644
--- a/scripts/audit-corpus.ts
+++ b/scripts/audit-corpus.ts
@@ -587,11 +587,12 @@ function htmlShape(value: string): string {
 }
 
 export function renderedText(value: string): string {
-  return decodeBasicEntities(value)
+  // Remove markup before decoding so escaped tags stay text, not HTML.
+  return decodeBasicEntities(value
     .replace(//g, '')
     .replace(/]*)?>[\s\S]*?<\/figcaption>/gi, '')
     .replace(/<\/?(?:address|article|aside|blockquote|br|div|figcaption|figure|footer|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|table|tbody|td|tfoot|th|thead|tr|ul)(?:\s[^>]*)?>/gi, ' ')
-    .replace(/<[^>]+>/g, '')
+    .replace(/<[^>]+>/g, ''))
     .replace(/\s+/g, ' ')
     .trim()
 }
diff --git a/scripts/conformance-data.ts b/scripts/conformance-data.ts
index 220e592..1a0ca50 100644
--- a/scripts/conformance-data.ts
+++ b/scripts/conformance-data.ts
@@ -66,7 +66,7 @@ export function evaluateCommonMark(): ConformanceResult {
 export function normalizeConformanceHtml(value: string): string {
   return value
     .trim()
-    .replace(/
/g, (_, lang: string) =>
+    .replace(/
/g, (_, lang: string) =>
       lang === 'plaintext' ? '
' : `
`,
     )
     .replace(/\n<\/code>/g, '')
diff --git a/skills/custom-extensions/SKILL.md b/skills/custom-extensions/SKILL.md
index a95dc40..4786b4e 100644
--- a/skills/custom-extensions/SKILL.md
+++ b/skills/custom-extensions/SKILL.md
@@ -2,7 +2,7 @@
 name: 'custom-extensions'
 description: >
   Implement MarkdownExtension block parsers, inline and document transforms,
-  HTML hooks, and portable ComponentNode output. Load when adding deterministic
+  HTML hooks, and portable block and inline component output. Load when adding deterministic
   custom syntax or rendering behavior across HTML, React, and Octane.
 metadata:
   type: core
@@ -111,6 +111,13 @@ console.log(html)
 
 Transforms receive built-in inline nodes and must return a deterministic replacement array.
 
+For custom inline UI, return an `InlineComponentNode` with `type: 'inlineComponent'`,
+`name`, `attributes`, inline `children`, and optional `tagName` and string `properties`.
+It uses the same emitted-tag component maps as a block `ComponentNode`, but defaults
+to `` instead of ``. Keep tag and property names under
+extension control, use phrasing content, and recurse through inline `children` when
+transforming text nested inside links or emphasis. This does not require `allowHtml`.
+
 ### Derive document metadata after parsing
 
 ```ts
@@ -370,7 +377,7 @@ export function Article() {
 }
 ```
 
-`renderHtml` hooks do not run in React or Octane; a `ComponentNode` and emitted-tag component mapping is the portable path.
+`renderHtml` hooks do not run in React or Octane; a `ComponentNode` or `InlineComponentNode` and emitted-tag component mapping is the portable path.
 
 Source: `docs/guides/extensions.md`
 
@@ -425,7 +432,7 @@ Source: `docs/guides/extensions.md`
 
 ### HIGH Rich output versus untrusted-content safety
 
-Prefer `ComponentNode` plus application components for rich output. Treat `allowHtml`, extension HTML strings, and highlighter markup as explicit trusted boundaries; see `production-pipelines`.
+Prefer block or inline component nodes plus application components for rich output. Treat `allowHtml`, extension HTML strings, and highlighter markup as explicit trusted boundaries; see `production-pipelines`.
 
 ### MEDIUM Parse-ahead performance versus option timing
 
diff --git a/skills/docs-features/references/docs-metadata.md b/skills/docs-features/references/docs-metadata.md
index 58338a1..4de29cb 100644
--- a/skills/docs-features/references/docs-metadata.md
+++ b/skills/docs-features/references/docs-metadata.md
@@ -317,4 +317,4 @@ interface CodeBlockNode {
 - `{2,4-6}` and `lines=2,4-6` produce sorted, unique positive line numbers.
 - Invalid ranges are ignored, and each individual range expands to at most 1,000 lines.
 
-HTML code blocks expose `data-lang`, `data-code-title`, `data-filename`, and `data-framework` when the corresponding values exist. Highlight lines are passed to a configured highlighter; metadata alone does not create token or line markup.
+HTML, React, and Octane code blocks expose `data-lang`, `data-meta`, `data-code-title`, `data-filename`, and `data-framework` on `
` when the corresponding values exist. Framework `pre` replacements can read `props['data-meta']`; highlighters receive the raw metadata in `options.meta` alongside highlight lines. Metadata alone does not create token or line markup.
diff --git a/skills/production-pipelines/SKILL.md b/skills/production-pipelines/SKILL.md
index 5e6f483..40f073f 100644
--- a/skills/production-pipelines/SKILL.md
+++ b/skills/production-pipelines/SKILL.md
@@ -331,6 +331,14 @@ export function renderWithPolicy(
 
 Core escaping and protocol filtering do not enforce application-specific outbound-link, image, or final-HTML policy.
 
+For parsed Markdown destinations, `urlTransform(url, kind, defaultUrl)` can return
+the screened default, a trusted replacement, or `null` to remove the link or image
+while retaining its label content. Only allow data images after application validation
+of their content, MIME type, and size. Keep the default policy for other destinations.
+Callback results are not screened again, and the callback does not apply to raw HTML,
+extension-created URLs, or ASTs supplied directly to renderers. Do not enable raw HTML
+to allow images. Include URL-policy changes in persisted AST cache invalidation.
+
 Source: `docs/core-concepts/security.md`
 
 ### HIGH Assuming complete CommonMark behavior
diff --git a/skills/render-markdown/references/ast-and-options.md b/skills/render-markdown/references/ast-and-options.md
index 7f65569..afdc8ff 100644
--- a/skills/render-markdown/references/ast-and-options.md
+++ b/skills/render-markdown/references/ast-and-options.md
@@ -190,10 +190,11 @@ Important rendering invariants:
 | `emphasis` | `EmphasisNode` | inline `children` |
 | `strike` | `StrikeNode` | inline `children` |
 | `footnoteReference` | `FootnoteReferenceNode` | normalized `id`, display `number`, optional `referenceIndex` |
-| `link` | `LinkNode` | sanitized `href`, optional `title`, inline `children` |
-| `image` | `ImageNode` | sanitized `src`, text `alt`, optional `title` |
+| `link` | `LinkNode` | policy-processed `href`, optional `title`, inline `children` |
+| `image` | `ImageNode` | policy-processed `src`, text `alt`, optional `title` |
 | `break` | `BreakNode` | no additional fields |
 | `inlineHtml` | `HtmlInlineNode` | raw `value` |
+| `inlineComponent` | `InlineComponentNode` | `name`, `attributes`, inline `children`, optional `tagName`, optional string `properties` |
 
 `HtmlInlineNode` is created only when parsing with `allowHtml: true`.
 Repeated footnote references use `referenceIndex` to produce unique source
@@ -206,10 +207,12 @@ import type {
   FootnoteDefinition,
   LinkReferenceDefinition,
   MarkdownExtension,
+  UrlTransform,
 } from '@tanstack/markdown'
 
 interface ParseOptions {
   allowHtml?: boolean
+  urlTransform?: UrlTransform
   frontmatter?: boolean
   headingIds?: boolean | ((text: string, index: number) => string)
   extensions?: MarkdownExtension[]
@@ -223,6 +226,7 @@ interface ParseOptions {
 | Option | Default | Contract |
 | --- | --- | --- |
 | `allowHtml` | `false` | Recognize raw block and inline HTML nodes. Rendering also requires this option to emit their raw values. |
+| `urlTransform` | built-in policy | Synchronous `(url, kind, defaultUrl) => string \| null`; return the screened default, a trusted replacement, or `null` to keep only label content. Applies to Markdown destinations, not raw HTML or supplied ASTs. |
 | `frontmatter` | `true` | Extract one leading `---` block into `document.frontmatter`. The library does not parse YAML. |
 | `headingIds` | `true` | Generate duplicate-safe IDs, disable IDs with `false`, or return an ID from `(text, normalizedLineIndex)`. |
 | `extensions` | `[]` | Run block parsers and inline/document transforms in array order. |
@@ -259,6 +263,7 @@ interface HeadingAnchorOptions {
 }
 
 interface CodeHighlightOptions {
+  meta?: string
   highlightLines?: number[]
   lineNumbers?: boolean
 }
diff --git a/src/html.ts b/src/html.ts
index 2666468..3ea2d9e 100644
--- a/src/html.ts
+++ b/src/html.ts
@@ -1,5 +1,5 @@
 import { parseMarkdown } from './parser.js'
-import type { BlockNode, ComponentNode, FootnoteItemNode, HeadingAnchorOptions, InlineNode, MarkdownDocument, MarkdownInput, RenderOptions, TableCellNode } from './types.js'
+import type { BlockNode, ComponentNode, FootnoteItemNode, HeadingAnchorOptions, InlineComponentNode, InlineNode, MarkdownDocument, MarkdownInput, RenderOptions, TableCellNode } from './types.js'
 import { escapeAttr, escapeHtml, footnoteReferenceId } from './utils.js'
 
 export function renderHtml(input: MarkdownInput, options: RenderOptions = {}): string {
@@ -76,6 +76,8 @@ export function renderInline(node: InlineNode, options: RenderOptions = {}): str
       return '
' case 'inlineHtml': return options.allowHtml ? node.value : escapeHtml(node.value) + case 'inlineComponent': + return renderComponent(node, options) } } @@ -90,10 +92,12 @@ function renderInlines(nodes: InlineNode[], options: RenderOptions): string { function renderCodeBlock(node: Extract, options: RenderOptions): string { const lang = node.lang ?? 'plaintext' const preAttrs = `class="tm-code${options.codeLineNumbers ? ' tm-code--line-numbers' : ''}" data-lang="${escapeAttr(lang)}"` + + (node.meta ? ` data-meta="${escapeAttr(node.meta)}"` : '') + (node.title ? ` data-code-title="${escapeAttr(node.title)}"` : '') + (node.file ? ` data-filename="${escapeAttr(node.file)}"` : '') + (node.framework ? ` data-framework="${escapeAttr(node.framework)}"` : '') const html = options.highlighter?.(node.value, lang, { + ...(node.meta && { meta: node.meta }), ...(node.highlightLines && { highlightLines: node.highlightLines }), ...(options.codeLineNumbers !== undefined && { lineNumbers: options.codeLineNumbers }), }) ?? escapeHtml(node.value) @@ -152,10 +156,12 @@ function renderFootnoteBackrefs(item: FootnoteItemNode): string { return result } -function renderComponent(node: ComponentNode, options: RenderOptions): string { - const tag = node.tagName ?? 'md-comment-component' +function renderComponent(node: ComponentNode | InlineComponentNode, options: RenderOptions): string { + const tag = node.tagName ?? (node.type === 'inlineComponent' ? 'span' : 'md-comment-component') const attrs = renderComponentAttrs(node) - const children = node.children.map(child => renderBlock(child, options)).join('\n') + const children = node.type === 'inlineComponent' + ? renderInlines(node.children, options) + : node.children.map(child => renderBlock(child, options)).join('\n') return `<${tag}${attrs}>${children}` } @@ -188,7 +194,7 @@ function renderHeadingAnchor(id: string | undefined, options: RenderOptions): st return `${escapeHtml(anchorOptions.content ?? '#')}` } -function renderComponentAttrs(node: ComponentNode): string { +function renderComponentAttrs(node: ComponentNode | InlineComponentNode): string { const props: Record = { ...node.properties, } diff --git a/src/inline.ts b/src/inline.ts index 3495bb8..32cbd28 100644 --- a/src/inline.ts +++ b/src/inline.ts @@ -93,8 +93,9 @@ function parseInlineRaw( const links = budget.links const children = parseInlineRaw(parsed.label, image ? (options.references ? { references: options.references } : {}) : options, budget) const nested = !image && budget.links !== links - const href = sanitizeUrl(parsed.href) - if (image || (!nested && (href || !parsed.href))) { + const defaultUrl = sanitizeUrl(parsed.href) + const href = options.urlTransform ? options.urlTransform(parsed.href, image ? 'image' : 'link', defaultUrl) : defaultUrl + if (href !== null && (image || (!nested && (href || !parsed.href)))) { pushText() nodes.push({ ...(image ? { type: 'image' as const, src: href, alt: plainText(children) } : { type: 'link' as const, href, children }), diff --git a/src/octane.ts b/src/octane.ts index 23f14c0..5c352de 100644 --- a/src/octane.ts +++ b/src/octane.ts @@ -2,7 +2,7 @@ import { Fragment, createElement } from 'octane' import type { ComponentBody, ElementDescriptor, OctaneNode } from 'octane' import { parseMarkdown } from './parser.js' import { footnoteReferenceId } from './utils.js' -import type { BlockNode, ComponentNode, FootnoteItemNode, InlineNode, MarkdownInput, RenderOptions, TableCellNode } from './types.js' +import type { BlockNode, ComponentNode, FootnoteItemNode, InlineComponentNode, InlineNode, MarkdownInput, RenderOptions, TableCellNode } from './types.js' type ComponentMap = Partial>> @@ -127,6 +127,8 @@ export function renderInlineOctane(node: InlineNode, options: MarkdownOctaneOpti return options.allowHtml ? h(options, 'span', { key, dangerouslySetInnerHTML: { __html: node.value } }) : node.value + case 'inlineComponent': + return renderComponentOctane(node, options, key) } } @@ -143,6 +145,7 @@ function renderCodeBlockOctane(node: Extract, optio ? { dangerouslySetInnerHTML: { __html: highlighter(node.value, lang, { + ...(node.meta && { meta: node.meta }), ...(node.highlightLines && { highlightLines: node.highlightLines }), ...(options.codeLineNumbers !== undefined && { lineNumbers: options.codeLineNumbers }), }), @@ -156,6 +159,7 @@ function renderCodeBlockOctane(node: Extract, optio { className: `tm-code${options.codeLineNumbers ? ' tm-code--line-numbers' : ''}`, 'data-lang': lang, + ...(node.meta ? { 'data-meta': node.meta } : {}), ...(node.title ? { 'data-code-title': node.title } : {}), ...(node.file ? { 'data-filename': node.file } : {}), ...(node.framework ? { 'data-framework': node.framework } : {}), @@ -278,8 +282,8 @@ function h( return createElement(component as string | ComponentBody | typeof Fragment, props ?? undefined, ...children) } -function renderComponentOctane(node: ComponentNode, options: MarkdownOctaneOptions, key?: string): ElementDescriptor { - const tag = node.tagName ?? 'md-comment-component' +function renderComponentOctane(node: ComponentNode | InlineComponentNode, options: MarkdownOctaneOptions, key?: string): ElementDescriptor { + const tag = node.tagName ?? (node.type === 'inlineComponent' ? 'span' : 'md-comment-component') const props: Record = { ...(node.properties ?? {}), } @@ -289,7 +293,9 @@ function renderComponentOctane(node: ComponentNode, options: MarkdownOctaneOptio if (!props['data-attributes']) props['data-attributes'] = JSON.stringify(node.attributes) } - return h(options, tag, { key, ...props }, node.children.map((child, index) => renderBlockOctane(child, options, `${key}:${index}`))) + return h(options, tag, { key, ...props }, node.type === 'inlineComponent' + ? renderInlines(node.children, options) + : node.children.map((child, index) => renderBlockOctane(child, options, `${key}:${index}`))) } function renderHeadingAnchorOctane(id: string | undefined, options: MarkdownOctaneOptions): OctaneNode { diff --git a/src/react.ts b/src/react.ts index aa4f6b2..5db4670 100644 --- a/src/react.ts +++ b/src/react.ts @@ -2,7 +2,7 @@ import { Fragment, createElement } from 'react' import type { ComponentPropsWithoutRef, ComponentType, JSX, ReactElement, ReactNode } from 'react' import { parseMarkdown } from './parser.js' import { footnoteReferenceId } from './utils.js' -import type { BlockNode, ComponentNode, FootnoteItemNode, InlineNode, MarkdownInput, RenderOptions, TableCellNode } from './types.js' +import type { BlockNode, ComponentNode, FootnoteItemNode, InlineComponentNode, InlineNode, MarkdownInput, RenderOptions, TableCellNode } from './types.js' type IntrinsicElementName = keyof JSX.IntrinsicElements type ComponentMap = Partial>> @@ -135,6 +135,8 @@ export function renderInlineReact(node: InlineNode, options: MarkdownReactOption return options.allowHtml ? h(options, 'span', { key, dangerouslySetInnerHTML: { __html: node.value } }) : node.value + case 'inlineComponent': + return renderComponentReact(node, options, key) } } @@ -146,10 +148,6 @@ function renderCodeBlockReact(node: Extract, option const lang = node.lang ?? 'plaintext' const highlighter = options.highlighter - const codeProps = { - className: `language-${lang}`, - } - const content = highlighter ? undefined : node.value const highlighted = @@ -157,6 +155,7 @@ function renderCodeBlockReact(node: Extract, option ? { dangerouslySetInnerHTML: { __html: highlighter(node.value, lang, { + ...(node.meta && { meta: node.meta }), ...(node.highlightLines && { highlightLines: node.highlightLines }), ...(options.codeLineNumbers !== undefined && { lineNumbers: options.codeLineNumbers }), }), @@ -170,11 +169,12 @@ function renderCodeBlockReact(node: Extract, option { className: `tm-code${options.codeLineNumbers ? ' tm-code--line-numbers' : ''}`, 'data-lang': lang, + ...(node.meta ? { 'data-meta': node.meta } : {}), ...(node.title ? { 'data-code-title': node.title } : {}), ...(node.file ? { 'data-filename': node.file } : {}), ...(node.framework ? { 'data-framework': node.framework } : {}), }, - h(options, 'code', { ...codeProps, ...highlighted }, content), + h(options, 'code', { className: `language-${lang}`, ...highlighted }, content), ) if (!node.title) return h(options, Fragment, { key }, pre) @@ -287,8 +287,8 @@ function h(options: MarkdownReactOptions, tag: string | typeof Fragment, props: return createElement(component, props, ...children) } -function renderComponentReact(node: ComponentNode, options: MarkdownReactOptions, key?: string): ReactElement { - const tag = node.tagName ?? 'md-comment-component' +function renderComponentReact(node: ComponentNode | InlineComponentNode, options: MarkdownReactOptions, key?: string): ReactElement { + const tag = node.tagName ?? (node.type === 'inlineComponent' ? 'span' : 'md-comment-component') const props: Record = { ...(node.properties ?? {}), } @@ -298,7 +298,9 @@ function renderComponentReact(node: ComponentNode, options: MarkdownReactOptions if (!props['data-attributes']) props['data-attributes'] = JSON.stringify(node.attributes) } - return h(options, tag, { key, ...props }, node.children.map((child, index) => renderBlockReact(child, options, `${key}:${index}`))) + return h(options, tag, { key, ...props }, node.type === 'inlineComponent' + ? renderInlines(node.children, options) + : node.children.map((child, index) => renderBlockReact(child, options, `${key}:${index}`))) } function renderHeadingAnchorReact(id: string | undefined, options: MarkdownReactOptions): ReactNode { diff --git a/src/types.ts b/src/types.ts index ea8e8ca..46a1d5d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -112,6 +112,11 @@ export interface ComponentNode { properties?: Record } +export interface InlineComponentNode extends Omit { + type: 'inlineComponent' + children: InlineNode[] +} + export type InlineNode = | TextNode | CodeSpanNode @@ -123,6 +128,7 @@ export type InlineNode = | ImageNode | BreakNode | HtmlInlineNode + | InlineComponentNode export interface TextNode { type: 'text' @@ -212,6 +218,7 @@ export interface HtmlRenderContext { export interface ParseOptions { allowHtml?: boolean + urlTransform?: UrlTransform frontmatter?: boolean headingIds?: boolean | ((text: string, index: number) => string) extensions?: MarkdownExtension[] @@ -221,6 +228,10 @@ export interface ParseOptions { footnoteCounts?: Record } +export interface UrlTransform { + (url: string, kind: 'link' | 'image', defaultUrl: string): string | null +} + export interface RenderOptions extends ParseOptions { highlighter?: CodeHighlighter codeLineNumbers?: boolean @@ -232,6 +243,7 @@ export interface CodeHighlighter { } export interface CodeHighlightOptions { + meta?: string highlightLines?: number[] lineNumbers?: boolean } diff --git a/src/utils.ts b/src/utils.ts index e17ef6e..dd82509 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,18 +6,21 @@ const htmlEscapes: Record = { '>': '>', '"': '"', "'": ''', + '`': '`', } +const escapeCharacter = (char: string) => htmlEscapes[char]! + export function normalizeInput(value: string): string { return value.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n') } export function escapeHtml(value: string): string { - return value.replace(/[&<>"']/g, char => htmlEscapes[char]!) + return value.replace(/[&<>"']/g, escapeCharacter) } export function escapeAttr(value: string): string { - return escapeHtml(value).replace(/`/g, '`') + return value.replace(/[&<>"'`]/g, escapeCharacter) } export function isBlank(value: string): boolean { diff --git a/tests/audit-regressions.test.tsx b/tests/audit-regressions.test.tsx index c32c749..8b65fad 100644 --- a/tests/audit-regressions.test.tsx +++ b/tests/audit-regressions.test.tsx @@ -8,7 +8,7 @@ import { Markdown as OctaneMarkdown } from '../src/octane.js' import { docsMarkdownExtensions } from '../src/extensions/docs.js' import { splitByHeading } from '../src/extensions/shared.js' import { transformBundlerTabs } from '../src/extensions/tabs.js' -import { sanitizeUrl } from '../src/utils.js' +import { escapeAttr, escapeHtml, sanitizeUrl } from '../src/utils.js' import type { MarkdownInput, RenderOptions } from '../src/types.js' import { normalizeStaticMarkup } from './helpers/normalize-html.js' @@ -20,6 +20,13 @@ function equivalent(input: MarkdownInput, options: RenderOptions = {}) { } describe('audit regressions', () => { + it('keeps single-pass attribute escaping identical to the existing policy', () => { + const source = Array.from({ length: 256 }, (_, index) => String.fromCharCode(index)).join('') + '\u2028\u2029©`' + expect(escapeAttr(source)).toBe(escapeHtml(source).replace(/`/g, '`')) + expect(escapeAttr('&<>"\'`')).toBe('&<>"'`') + expect(escapeHtml('`')).toBe('`') + }) + it.each([ ['`a b`', 'a b'], ['` a b `', 'a b'], diff --git a/tests/bundle-size.test.ts b/tests/bundle-size.test.ts index c14e6eb..5fef87a 100644 --- a/tests/bundle-size.test.ts +++ b/tests/bundle-size.test.ts @@ -19,18 +19,19 @@ describe('bundle budgets', () => { ['react'], ) - // The September 11 audited working tree is the ceiling, not the larger npm release. + // Measured ceilings for the #7, #9, and #11 fixes, with no spare headroom. + // Extension entries retain their 0.0.14 ceilings. for (const [result, min, gzip, brotli] of [ - [parser, 13440, 4948, 4559], - [html, 18453, 6737, 6169], - [react, 18491, 6682, 6153], - [octane, 18494, 6681, 6145], - [pluggable, 18489, 6759, 6195], + [parser, 13157, 4975, 4593], + [html, 18337, 6807, 6240], + [react, 18271, 6722, 6184], + [octane, 18283, 6727, 6194], + [pluggable, 18373, 6829, 6260], [streaming, 699, 311, 253], - [callouts, 556, 372, 329], - [reactStreaming, 19182, 6868, 6316], - [docs, 6589, 2334, 2121], - [tabs, 3320, 1232, 1094], + [callouts, 506, 335, 278], + [reactStreaming, 18962, 6907, 6356], + [docs, 6423, 2292, 2073], + [tabs, 3290, 1221, 1082], ] as const) { expect(result.minBytes).toBeLessThanOrEqual(min) expect(result.gzipBytes).toBeLessThanOrEqual(gzip) @@ -41,19 +42,20 @@ describe('bundle budgets', () => { it('also protects the complete namespace of every public entry point', async () => { const budgets: Record = { - '.': [18714, 6864, 6272], - './html': [18676, 6845, 6267], - './parser': [13572, 5032, 4635], - './react': [18691, 6790, 6242], - './octane': [18697, 6790, 6239], - './extensions/callouts': [710, 467, 390], - './extensions/comment-components': [1159, 656, 554], - './extensions/docs': [6753, 2433, 2198], + '.': [18598, 6936, 6345], + './html': [18560, 6920, 6331], + './parser': [13289, 5061, 4664], + './react': [18470, 6828, 6279], + './octane': [18485, 6833, 6287], + './extensions/callouts': [660, 432, 360], + './extensions/comment-components': [1073, 647, 542], + './extensions/docs': [6587, 2392, 2162], './extensions/framework': [1470, 734, 630], './extensions/headings': [1038, 573, 480], './extensions/streaming': [838, 403, 334], - './extensions/tabs': [3567, 1350, 1197], + './extensions/tabs': [3537, 1338, 1185], } + expect(publicEntries.map(entry => entry.name).sort()).toEqual(Object.keys(budgets).sort()) for (const entry of publicEntries) { const result = await bundle(entry.contents, entry.external) const budget = budgets[entry.name]! diff --git a/tests/corpus-audit.test.ts b/tests/corpus-audit.test.ts index 039de05..a6e5d91 100644 --- a/tests/corpus-audit.test.ts +++ b/tests/corpus-audit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { classifyDocument, compareRenderedHtml, detectCorpusFeatures, locateTextDifference } from '../scripts/audit-corpus.js' +import { classifyDocument, compareRenderedHtml, detectCorpusFeatures, locateTextDifference, renderedText } from '../scripts/audit-corpus.js' describe('practical corpus audit', () => { it('detects supported, extension, and unsupported syntax outside code fences', () => { @@ -75,4 +75,12 @@ Title reference: 'Before reference after', }) }) + + it('does not mistake escaped code or metadata for HTML tags', () => { + const code = '
<App />
' + expect(renderedText(code)).toBe('') + expect(renderedText('

<!--literal--> &lt;tag&gt;

')).toBe(' <tag>') + expect(compareRenderedHtml(code, '
<App />
')).not.toBe('content') + expect(compareRenderedHtml('<App />', '')).toBe('content') + }) }) diff --git a/tests/inline-component.types.ts b/tests/inline-component.types.ts new file mode 100644 index 0000000..b38c677 --- /dev/null +++ b/tests/inline-component.types.ts @@ -0,0 +1,21 @@ +import type { BlockNode, InlineComponentNode, InlineNode, UrlTransform } from '../src/index.js' + +export const inline: InlineComponentNode = { + type: 'inlineComponent', name: 'badge', attributes: {}, children: [{ type: 'text', value: 'Ready' }], +} + +export const inParagraph: BlockNode = { type: 'paragraph', children: [inline] } + +export const invalidInline: InlineComponentNode = { + type: 'inlineComponent', name: 'badge', attributes: {}, + // @ts-expect-error Inline components cannot contain block children. + children: [{ type: 'paragraph', children: [] }], +} + +// @ts-expect-error Block components are not valid inline nodes. +export const blockInInline: InlineNode = { type: 'component', name: 'block', attributes: {}, children: [] } + +export const urlPolicy: UrlTransform = (_url, kind, defaultUrl) => kind === 'image' ? null : defaultUrl + +// @ts-expect-error A URL policy must return a string or null, not undefined. +export const invalidUrlPolicy: UrlTransform = () => undefined diff --git a/tests/issue-regressions.test.tsx b/tests/issue-regressions.test.tsx new file mode 100644 index 0000000..c19538f --- /dev/null +++ b/tests/issue-regressions.test.tsx @@ -0,0 +1,162 @@ +import { createElement as createReactElement } from 'react' +import type { ReactNode } from 'react' +import { renderToStaticMarkup as renderReact } from 'react-dom/server' +import { createElement as createOctaneElement } from 'octane' +import type { OctaneNode } from 'octane' +import { renderToStaticMarkup as renderOctane } from 'octane/server' +import { describe, expect, it, vi } from 'vitest' +import { renderHtml } from '../src/html.js' +import { parseMarkdown } from '../src/parser.js' +import { Markdown as ReactMarkdown } from '../src/react.js' +import { Markdown as OctaneMarkdown } from '../src/octane.js' +import type { CodeHighlighter, InlineComponentNode, InlineNode, MarkdownDocument, MarkdownExtension, RenderOptions } from '../src/types.js' +import { headingCollectionExtension } from '../src/extensions/headings.js' +import { normalizeStaticMarkup } from './helpers/normalize-html.js' + +function renderAll(source: string | MarkdownDocument, options: RenderOptions = {}) { + const html = renderHtml(source, options) + expect(normalizeStaticMarkup(renderReact(createReactElement(ReactMarkdown, { children: source, ...options })))).toBe(normalizeStaticMarkup(html)) + expect(normalizeStaticMarkup(renderOctane(OctaneMarkdown, { children: source, ...options }).html)).toBe(normalizeStaticMarkup(html)) + return html +} + +describe('GitHub issue triage', () => { + it.each([ + ['*a **b** c*', 'a b c'], + ['*see **the docs** here*', 'see the docs here'], + ['_italic __bold__ tail_', 'italic bold tail'], + ['*outer **inner***', 'outer inner'], + ])('keeps the closed nested-emphasis report fixed: %s (#2)', (source, expected) => { + expect(renderAll(source)).toBe(`

${expected}

`) + }) + + it.each(['\n', '\r\n', '\r'])('normalizes line endings without losing content: %j (#6)', newline => { + expect(renderAll(`first${newline}second${newline}${newline}third`)).toBe('

first\nsecond

\n

third

') + }) + + it('preserves raw code metadata for applications rendering an AST (#7)', () => { + const source = '```js renderer="demo" {1}\nconsole.log(1)\n```' + const document = parseMarkdown(source) + expect(document.children[0]).toEqual({ + type: 'code', value: 'console.log(1)', lang: 'js', meta: 'renderer="demo" {1}', highlightLines: [1], + }) + expect(renderAll(JSON.parse(JSON.stringify(document)))).toBe(renderHtml(source)) + }) + + it('forwards escaped fence metadata to pre elements and unescaped metadata to highlighters (#7)', () => { + const meta = 'renderer="demo" {1} & ' + const highlighter = vi.fn(() => 'highlighted') + const html = renderAll(`\`\`\`js ${meta}\nconsole.log(1)\n\`\`\``, { highlighter, codeLineNumbers: false }) + expect(html).toContain('data-meta="renderer="demo" {1} & <unsafe>"') + expect(html).toContain('highlighted') + expect(highlighter).toHaveBeenCalledTimes(3) + for (const call of highlighter.mock.calls) { + expect(call).toEqual(['console.log(1)', 'js', { meta, highlightLines: [1], lineNumbers: false }]) + } + }) + + it('exposes fence metadata to framework pre replacements (#7)', () => { + const source = '> ```js renderer="demo"\n> code\n> ```' + const reactMeta: unknown[] = [] + const octaneMeta: unknown[] = [] + renderReact(createReactElement(ReactMarkdown, { children: source, components: { + pre: (props: Record) => { + reactMeta.push(props['data-meta']) + return createReactElement('pre', null, 'replacement') + }, + } })) + renderOctane(OctaneMarkdown, { children: source, components: { + pre: (props: Record) => { + octaneMeta.push(props['data-meta']) + return createOctaneElement('pre', null, 'replacement') + }, + } }) + expect(reactMeta).toEqual(['renderer="demo"']) + expect(octaneMeta).toEqual(reactMeta) + }) + + it('leaves metadata-free code output and highlighter options unchanged (#7)', () => { + const highlighter = vi.fn(() => 'code') + expect(renderAll('```js\ncode\n```', { highlighter })).toBe('
code
') + expect(highlighter).toHaveBeenCalledWith('code', 'js', {}) + }) + + it('renders application-validated data images through the existing AST input (#11)', () => { + const src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=' + const document: MarkdownDocument = { type: 'root', children: [{ type: 'paragraph', children: [{ + type: 'image', src, alt: 'Pixel', + }] }] } + expect(renderAll(document)).toBe(`

Pixel

`) + expect(renderHtml(`![Pixel](${src})`)).toBe('

Pixel

') + expect(renderHtml('[unsafe](javascript:alert%281%29)')).toBe('

unsafe

') + }) + + it('supports semantic block components without enabling raw HTML (#13)', () => { + const document: MarkdownDocument = { type: 'root', children: [{ + type: 'component', name: 'math', tagName: 'math-block', attributes: {}, + properties: { tex: 'E = mc^2