@wxxb789/anc 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/astro.config.mjs +255 -0
  4. package/bin/anc.mjs +798 -0
  5. package/package.json +93 -0
  6. package/public/_headers +45 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/favicon.svg +9 -0
  9. package/public/fonts/Temml.woff2 +0 -0
  10. package/scripts/build-snapshot.js +69 -0
  11. package/scripts/build-wasm.js +67 -0
  12. package/scripts/build-workspace.js +50 -0
  13. package/scripts/copy-snapshot.js +48 -0
  14. package/scripts/copy-wasm.js +57 -0
  15. package/scripts/emit-redirects.js +64 -0
  16. package/scripts/generate-corpus.js +421 -0
  17. package/scripts/init-repository.js +249 -0
  18. package/scripts/load-config.js +975 -0
  19. package/scripts/markdown-to-artifact.js +1286 -0
  20. package/scripts/preview-site.js +364 -0
  21. package/scripts/publish-set-review.js +157 -0
  22. package/scripts/resolve-links.js +640 -0
  23. package/scripts/run-pagefind.js +125 -0
  24. package/scripts/scan-residue.js +1016 -0
  25. package/scripts/scan-secrets.js +268 -0
  26. package/scripts/seed-gitignore.js +410 -0
  27. package/scripts/snapshot-rows.js +205 -0
  28. package/scripts/validate-content.js +95 -0
  29. package/scripts/vendor-provenance.js +307 -0
  30. package/scripts/verify-output-inventory.js +307 -0
  31. package/scripts/write-report.js +351 -0
  32. package/scripts/write-snapshot.js +136 -0
  33. package/src/components/CollectionExplorer.astro +149 -0
  34. package/src/components/FacetIndex.astro +45 -0
  35. package/src/components/LinkedNotes.astro +94 -0
  36. package/src/components/NoteGraph.astro +434 -0
  37. package/src/components/NoteList.astro +54 -0
  38. package/src/components/SiteMetadata.astro +143 -0
  39. package/src/components/TableOfContents.astro +69 -0
  40. package/src/components/TagBrowser.astro +70 -0
  41. package/src/layouts/Layout.astro +305 -0
  42. package/src/lib/artifact-source.js +72 -0
  43. package/src/lib/build-bindings.js +11 -0
  44. package/src/lib/collection-navigation.js +145 -0
  45. package/src/lib/content.js +92 -0
  46. package/src/lib/diagram-mode.js +18 -0
  47. package/src/lib/graph-layout.js +219 -0
  48. package/src/lib/graph-selection.js +84 -0
  49. package/src/lib/graph.js +42 -0
  50. package/src/lib/link-resolution.js +389 -0
  51. package/src/lib/markdown.js +1469 -0
  52. package/src/lib/math-mode.js +31 -0
  53. package/src/lib/math-output.js +131 -0
  54. package/src/lib/math.js +165 -0
  55. package/src/lib/mermaid-environment.js +475 -0
  56. package/src/lib/mermaid-render.js +769 -0
  57. package/src/lib/preview-model.js +96 -0
  58. package/src/lib/relations.js +242 -0
  59. package/src/lib/rendered-marker.js +55 -0
  60. package/src/lib/route-path.js +192 -0
  61. package/src/lib/routes.js +342 -0
  62. package/src/lib/schema.js +578 -0
  63. package/src/lib/site.js +578 -0
  64. package/src/lib/snapshot-contract.js +216 -0
  65. package/src/lib/snapshot-operations.js +147 -0
  66. package/src/lib/snapshot-queries.js +216 -0
  67. package/src/lib/snapshot-reader.js +210 -0
  68. package/src/lib/snapshot.js +233 -0
  69. package/src/lib/sqlite-warning.js +22 -0
  70. package/src/lib/sqlite.js +14 -0
  71. package/src/lib/tag-browser-model.js +18 -0
  72. package/src/lib/translations.js +533 -0
  73. package/src/lib/wasm-asset.js +47 -0
  74. package/src/lib/worker-protocol.js +93 -0
  75. package/src/pages/404.astro +35 -0
  76. package/src/pages/about.astro +100 -0
  77. package/src/pages/collections/[slug].astro +36 -0
  78. package/src/pages/collections/index.astro +33 -0
  79. package/src/pages/graph.astro +64 -0
  80. package/src/pages/index.astro +33 -0
  81. package/src/pages/notes/[slug].astro +509 -0
  82. package/src/pages/privacy.astro +121 -0
  83. package/src/pages/private.astro +59 -0
  84. package/src/pages/recent.astro +33 -0
  85. package/src/pages/robots.txt.js +15 -0
  86. package/src/pages/rss.xml.js +21 -0
  87. package/src/pages/sitemap.xml.js +20 -0
  88. package/src/pages/tags/[tag].astro +56 -0
  89. package/src/pages/tags/index.astro +37 -0
  90. package/src/scripts/code-copy.js +45 -0
  91. package/src/scripts/diagram-disabled.js +16 -0
  92. package/src/scripts/diagram.js +322 -0
  93. package/src/scripts/graph-client.js +222 -0
  94. package/src/scripts/graph-draw.js +166 -0
  95. package/src/scripts/link-preview.js +334 -0
  96. package/src/scripts/math-disabled.js +18 -0
  97. package/src/scripts/math.js +133 -0
  98. package/src/scripts/preferences.js +123 -0
  99. package/src/scripts/search-dialog.js +651 -0
  100. package/src/scripts/snapshot-client.js +157 -0
  101. package/src/scripts/snapshot-worker.js +341 -0
  102. package/src/scripts/tag-browser.js +153 -0
  103. package/src/scripts/theme-init.js +35 -0
  104. package/src/scripts/toc-active.js +38 -0
  105. package/src/styles/code.css +152 -0
  106. package/src/styles/diagram.css +149 -0
  107. package/src/styles/global.css +1739 -0
  108. package/src/styles/graph.css +317 -0
  109. package/src/styles/math.css +376 -0
  110. package/src/styles/tokens.css +171 -0
  111. package/tsconfig.json +25 -0
@@ -0,0 +1,1469 @@
1
+ /**
2
+ * Build-time Markdown rendering for the public content artifact.
3
+ *
4
+ * One module owns the whole path from artifact Markdown to sanitized HTML plus
5
+ * the structured metadata pages need. Two properties are load-bearing:
6
+ *
7
+ * 1. **Sanitization is last.** Every transform — highlighting, callouts, heading
8
+ * anchors, internal-link rewriting — happens before `sanitizeHtml` runs, and
9
+ * `sanitizeHtml` is the single exit from `renderMarkdown`. Nothing a transform
10
+ * emits, including a route returned by an injected `routeForSlug`, can reach
11
+ * the page without passing the allowlist.
12
+ * 2. **Rendering is deterministic.** Identical Markdown yields byte-identical
13
+ * HTML: the slugger is per-render, no timestamps or random ids are emitted,
14
+ * and no transform depends on ambient state.
15
+ *
16
+ * Toolchain, all already present via Astro 7 and pinned as direct dependencies:
17
+ * `satteri` (Astro 7's Markdown processor), `github-slugger`, `@astrojs/prism`,
18
+ * and `prismjs` for its language manifest. Nothing new was installed.
19
+ *
20
+ * Prism rather than Shiki: Shiki's output carries inline `style` attributes,
21
+ * which the target CSP (`style-src 'self'`, no `unsafe-inline`) blocks. Prism
22
+ * emits class-only markup that the CSP allows unchanged.
23
+ */
24
+ import { markdownToHtml } from 'satteri';
25
+ import Slugger from 'github-slugger';
26
+ import sanitizeHtml from 'sanitize-html';
27
+ import { runHighlighterWithAstro } from '@astrojs/prism/dist/highlighter';
28
+ import prismComponents from 'prismjs/components.json' with { type: 'json' };
29
+ import { renderMath } from "./math.js";
30
+ import { DIAGRAM_MODE } from "./diagram-mode.js";
31
+ import { MATH_MODE } from "./math-mode.js";
32
+ import { RENDERED_DIAGRAM, RENDERED_MARKER, RENDERED_MATH } from "./rendered-marker.js";
33
+ import { NAV_LANGUAGE, translate } from "./translations.js";
34
+ import { slugFromSegment } from "./route-path.js";
35
+ /**
36
+ * Today's route shape, which the producer already emits. Keeping it as the
37
+ * default means rendering is unchanged until TK-04 injects its own mapping.
38
+ */
39
+ export const defaultRouteForSlug = (slug) => `/${slug}/`;
40
+ /**
41
+ * Headings needed before a page gets a table of contents.
42
+ *
43
+ * TK-03 scope item 6 says "pages above a length threshold". Heading count is
44
+ * used as that threshold rather than character count: a table of contents is
45
+ * navigation, and what makes it useful is having several places to navigate to,
46
+ * not having many words. A 5000-word essay with one heading gains nothing from
47
+ * a one-item list, and a short page with four sections genuinely benefits.
48
+ * Three is the point at which the list stops restating the page.
49
+ */
50
+ export const TOC_MIN_HEADINGS = 3;
51
+ /**
52
+ * Rendering policy, stated in one place because each flag is a decision:
53
+ *
54
+ * - `gfm` — tables, footnotes, strikethrough, and task lists (section 15.1).
55
+ * - `frontmatter` off — the producer strips frontmatter, so a leading `---` in
56
+ * the body is a thematic break. Leaving this on would silently eat content.
57
+ * - `math` — display math parses; `$…$` does not, because a single `$` in
58
+ * technical prose is far more often currency (`$5 to $10`) than an equation,
59
+ * and misparsing it corrupts the sentence. TK-15 renders what does parse as
60
+ * native MathML; see `src/lib/math.ts`.
61
+ * - `smartPunctuation` off — it rewrites `--` to an en-dash, which is wrong for
62
+ * the CLI flags this corpus is full of.
63
+ * - `wikilinks` off — the producer resolves public wikilinks to routes, and
64
+ * TK-01 rejects any artifact still carrying `[[`. Parsing them here would
65
+ * quietly render a link the projection never approved (section 15.2).
66
+ * - `headingAttributes` off — `# text { #id .class }` would let body content
67
+ * choose its own ids and classes, defeating the anchor and class allowlists.
68
+ */
69
+ function featuresFor(chrome) {
70
+ return {
71
+ // GFM's footnote section carries two strings of its own — the visually
72
+ // hidden `<h2>` and each backref's `aria-label` — and they are English
73
+ // defaults unless supplied. A Chinese article with a footnote therefore
74
+ // announced "Back to reference 1" to a screen reader, which is the same
75
+ // defect as an English heading anchor and is fixed the same way. satteri
76
+ // takes them directly, so this costs no dependency and no parsing.
77
+ //
78
+ // `{reference}` is satteri's placeholder, substituted with `1` or `1-2`; the
79
+ // locale receives it as the interpolated value, so a locale is free to put
80
+ // the number wherever its grammar wants it.
81
+ gfm: {
82
+ footnotes: {
83
+ label: chrome.footnotesHeading,
84
+ backLabel: chrome.footnoteBackLabel('{reference}'),
85
+ },
86
+ },
87
+ frontmatter: false,
88
+ math: { singleDollarTextMath: false },
89
+ smartPunctuation: false,
90
+ wikilinks: false,
91
+ headingAttributes: false,
92
+ };
93
+ }
94
+ /** Class satteri puts on math code nodes; also the fence language for math. */
95
+ const MATH_LANGUAGE = 'math';
96
+ /** The fence language that carries a diagram. */
97
+ const MERMAID_LANGUAGE = 'mermaid';
98
+ /**
99
+ * Fence languages that are never handed to the highlighter.
100
+ *
101
+ * Both are rendered rather than highlighted. Math becomes native MathML via
102
+ * Temml (`src/lib/math.ts`), and a Mermaid fence becomes either a build-time
103
+ * SVG or a client-rendered diagram depending on `DIAGRAM_MODE` — in the client
104
+ * case its source ships as escaped text for the runtime to read, which is also
105
+ * what a reader without JavaScript is left with.
106
+ */
107
+ const UNHIGHLIGHTED_LANGUAGES = new Set([MATH_LANGUAGE, MERMAID_LANGUAGE]);
108
+ /**
109
+ * Every language id and alias Prism ships a grammar for, read from Prism's own
110
+ * manifest rather than hand-listed, so the set never drifts from the installed
111
+ * version.
112
+ *
113
+ * Asking Prism for a language it lacks makes it log two console lines per block
114
+ * and return the source unhighlighted. Gating on this set keeps the build log
115
+ * readable and, more usefully, lets `highlight` map a near-miss alias (`cmd` →
116
+ * `batch`) onto a grammar that does exist instead of silently giving up.
117
+ */
118
+ const PRISM_LANGUAGES = new Set(Object.entries(prismComponents.languages).flatMap(([id, meta]) => id === 'meta' ? [] : [id, ...[(meta.alias ?? [])].flat()]));
119
+ /**
120
+ * Prism languages that permanently rewrite *another* language's grammar when
121
+ * loaded — `jsdoc` enriches `javascript`, `css-extras` enriches `css`, and
122
+ * `css`/`javascript` themselves enrich `markup`.
123
+ *
124
+ * Prism's registry is a process-wide singleton and its loader is lazy, so
125
+ * without this the same `js` fence highlights differently depending on whether
126
+ * some *other* page in the same build happened to pull in `jsdoc` first. That
127
+ * is a real determinism break, not a theoretical one: `/** @param a *\/` renders
128
+ * as `token comment` before and `token doc-comment comment` after.
129
+ *
130
+ * Loading every modifier once at module init settles the registry before the
131
+ * first render, so grammar state no longer depends on page order. The list is
132
+ * derived from the manifest's own `modify` field rather than hand-written, and
133
+ * costs about 30 ms once per build.
134
+ */
135
+ const GRAMMAR_MODIFIERS = Object.entries(prismComponents.languages)
136
+ .filter(([id, meta]) => id !== 'meta' && 'modify' in meta)
137
+ .map(([id]) => id);
138
+ /**
139
+ * Settle Prism's global grammar registry exactly once, before any render reads
140
+ * it. Awaited by every call to {@link highlight}, so no render can observe a
141
+ * half-loaded registry.
142
+ *
143
+ * `loadLanguages` is Prism's own bulk loader and resolves each component's
144
+ * dependencies itself. It is used directly rather than through the highlighter
145
+ * because five of these components (`css-extras`, `js-extras`, `js-templates`,
146
+ * `php-extras`, `xml-doc`) only patch another grammar and register none of their
147
+ * own, which the highlighter would report as a load failure.
148
+ */
149
+ const grammarsReady = (async () => {
150
+ const { createRequire } = await import('node:module');
151
+ const require = createRequire(import.meta.url);
152
+ require('prismjs');
153
+ const loadLanguages = require('prismjs/components/index.js');
154
+ loadLanguages(GRAMMAR_MODIFIERS);
155
+ })();
156
+ /**
157
+ * Fence languages this corpus uses that Prism names differently. Kept tiny and
158
+ * explicit: a wrong guess mislabels a code block, so only unambiguous synonyms
159
+ * belong here.
160
+ */
161
+ const LANGUAGE_ALIASES = {
162
+ cmd: 'batch',
163
+ ps1: 'powershell',
164
+ text: 'plaintext',
165
+ };
166
+ /**
167
+ * A single-segment root-relative href, `/<segment>/` with an optional fragment.
168
+ *
169
+ * The segment is read through `slugFromSegment`, the shared slug grammar, and
170
+ * not matched as a slug here. Measured against the installed `satteri`: a body
171
+ * link written `[a](/日记-今天/)` is emitted **percent-encoded**, as
172
+ * `href="/%E6%97%A5%E8%AE%B0-%E4%BB%8A%E5%A4%A9/"`, so a pattern over raw
173
+ * letters would never see a non-ASCII slug. Decoding first accepts both forms,
174
+ * and anything that does not decode to a slug is left untouched.
175
+ */
176
+ const INTERNAL_HREF = /^\/([^/?#\s]+)\/(#[^\s]*)?$/;
177
+ /**
178
+ * Placeholders that survive sanitization and are replaced with rendered markup
179
+ * afterwards.
180
+ *
181
+ * Two constructs — MathML and diagram SVG — cannot pass through
182
+ * `sanitize-html`. Both `math` and `svg` are on its `nonTextTags` list, and
183
+ * they are there deliberately: an SVG can carry a script, and widening the
184
+ * allowlist to admit *this* SVG would admit every raw-HTML `<svg>` a note body
185
+ * chose to write. Requirements §15.2 rejects exactly that.
186
+ *
187
+ * So each is rendered by a module that has already established its own, much
188
+ * narrower guarantee — Temml emits a closed element set from a parsed TeX tree;
189
+ * `mermaid-render.ts` strips scripts, handlers, styles, and external references
190
+ * and fails the build rather than passing one through — and the sanitized HTML
191
+ * carries a marker where it goes.
192
+ *
193
+ * **The marker's safety is what makes this sound**, and it rests on three
194
+ * properties rather than on the string being unusual:
195
+ *
196
+ * 1. The token contains no character `escapeHtml` rewrites and none that is
197
+ * special in HTML, so it passes through the sanitizer's text handling
198
+ * byte-for-byte — asserted by test rather than assumed.
199
+ * 2. It is emitted as a **text node**, so a note body that writes the literal
200
+ * token in its prose gets it escaped… no: a text node is escaped on output
201
+ * only for `&<>`, which the token has none of. The real protection is (3).
202
+ * 3. Substitution is **positional and exhausting**: each token is replaced
203
+ * exactly once, in index order, and `substituteDiagrams` fails if the count
204
+ * of markers found does not equal the count of renders requested. A body that
205
+ * writes the token itself therefore fails the build loudly instead of
206
+ * receiving somebody else's diagram — and cannot inject markup either way,
207
+ * since the replacement text is chosen by index from this render's own list.
208
+ */
209
+ const DIAGRAM_TOKEN_PREFIX = 'renderedDiagramPlaceholder';
210
+ const DIAGRAM_TOKEN_SUFFIX = 'End';
211
+ const MATH_TOKEN_PREFIX = 'renderedMathPlaceholder';
212
+ const MATH_TOKEN_SUFFIX = 'End';
213
+ /**
214
+ * The id shapes satteri generates for GFM footnotes, plus its footnote-section
215
+ * heading. These are structural, not authored, so they are recognized by shape
216
+ * rather than collected.
217
+ */
218
+ const FOOTNOTE_ID = /^(?:user-content-fn(?:ref)?-[^\s"]+|footnote-label)$/;
219
+ /**
220
+ * An inline image TK-01's artifact scanner already allows: a base64 raster
221
+ * type, and nothing else. `image/svg+xml` is deliberately excluded — an SVG can
222
+ * carry a script — as are every non-image type and the bare `data:,` form.
223
+ * Mirrors `DISALLOWED_DATA_URI` in `schema.ts`; the two must agree, or the
224
+ * producer admits an image this renderer then silently drops.
225
+ */
226
+ const SAFE_IMAGE_DATA_URI = /^data:image\/(?:png|jpe?g|gif|webp|avif);base64,[A-Za-z0-9+/=]*$/i;
227
+ const HTML_ESCAPES = {
228
+ '&': '&amp;',
229
+ '<': '&lt;',
230
+ '>': '&gt;',
231
+ '"': '&quot;',
232
+ "'": '&#39;',
233
+ };
234
+ function escapeHtml(text) {
235
+ return text.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character] ?? character);
236
+ }
237
+ /**
238
+ * Highlighted markup for one code block.
239
+ *
240
+ * Anything without a Prism grammar is escaped here rather than handed over:
241
+ * Prism returns its input *verbatim* when it cannot highlight, so a fence such
242
+ * as ```` ```nosuchlang ```` would otherwise put raw, unescaped HTML into the
243
+ * document. The identity check afterwards is a second guard for a grammar that
244
+ * exists but tokenizes nothing. Sanitization is still the last line of defence,
245
+ * not the first.
246
+ *
247
+ * Prism's token classes are namespaced to `token-*` on the way out. Prism's
248
+ * vocabulary is large and open-ended (`comment`, `keyword`, `section`, `title`,
249
+ * `code-block`, …), and several of its names collide with real layout classes,
250
+ * so an allowlist wide enough to admit them would also admit any lowercase
251
+ * class a note body invented. Namespacing lets the allowlist close to exactly
252
+ * `token` plus `token-*`. Rewriting is safe because Prism has already escaped
253
+ * the code, so the only `class="…"` left in this string is Prism's own.
254
+ */
255
+ function namespaceTokenClasses(html) {
256
+ return html.replace(/class="([^"]*)"/g, (_match, list) => {
257
+ const namespaced = list
258
+ .split(/\s+/)
259
+ .filter(Boolean)
260
+ .map((name) => (name === 'token' ? name : `token-${name}`));
261
+ return `class="${namespaced.join(' ')}"`;
262
+ });
263
+ }
264
+ async function highlight(code, language) {
265
+ const grammar = LANGUAGE_ALIASES[language] ?? language;
266
+ if (UNHIGHLIGHTED_LANGUAGES.has(language) || !PRISM_LANGUAGES.has(grammar))
267
+ return escapeHtml(code);
268
+ await grammarsReady;
269
+ const { html } = await runHighlighterWithAstro(grammar, code);
270
+ return html === code ? escapeHtml(code) : namespaceTokenClasses(html);
271
+ }
272
+ /**
273
+ * The tags `POLICY` grants an `id`, and therefore the only tags on which a
274
+ * raw-HTML id can survive to the page. Derived from the allowlist rather than
275
+ * restated, so the two cannot disagree.
276
+ *
277
+ * A function, not a `const`: `POLICY` is declared further down the module, so an
278
+ * eagerly evaluated binding here reads it inside its temporal dead zone and the
279
+ * module throws on import. Computed once on first call and cached — every render
280
+ * hits this per raw node.
281
+ */
282
+ let idBearingTags;
283
+ function isIdBearing(tagName) {
284
+ idBearingTags ??= new Set(Object.entries(POLICY.allowedAttributes ?? {})
285
+ .filter(([, allowed]) => allowed.includes('id'))
286
+ .map(([tag]) => tag));
287
+ return idBearingTags.has(tagName);
288
+ }
289
+ /**
290
+ * Every `id` in a fragment of raw HTML that could actually reach the page.
291
+ *
292
+ * Parsed rather than pattern-matched. A regex over the raw text gets this wrong
293
+ * in both directions, and both directions are defects:
294
+ *
295
+ * - It *misses* ids. An attribute value is entity-decoded before it becomes an
296
+ * id, and `_` — a slug character — has named references (`&lowbar;`,
297
+ * `&UnderBar;`). `<a id="foo&lowbar;bar">` is the id `foo_bar`, and a decoder
298
+ * that handles only numeric references reads it as the literal
299
+ * `foo&lowbar;bar`, reserves the wrong string, and lets the decoy take
300
+ * `## Foo_Bar`'s anchor — exactly the defect this exists to close.
301
+ * - It *over-matches*. `\bid\s*=` fires inside an HTML comment, inside
302
+ * `data-id=`, and inside a `title="id=x"` value, so text that never renders
303
+ * would push a real heading from `#introduction` to `#introduction-1` and
304
+ * silently move a published deep link.
305
+ *
306
+ * `sanitize-html` is already this module's parser and its allowlist, so using it
307
+ * here costs no dependency and cannot disagree with the sanitizer about what an
308
+ * id *is*. But parsing alone is still too generous, because the question here is
309
+ * not "is this an id" — it is "can this id shadow a heading anchor", and only an
310
+ * id that survives `sanitize()` can. So the same two filters the real pass
311
+ * applies are applied here:
312
+ *
313
+ * - `nonTextTags` from `POLICY`, so an id inside a discarded subtree written as
314
+ * one raw block (`<form><a id="x">y</a></form>`) reserves nothing. Passed
315
+ * explicitly because the library's default list is a different, shorter one.
316
+ * - {@link isIdBearing}, so `<div id="introduction">` — which ships as a `div`
317
+ * with its id dropped — cannot push `## Introduction` to `#introduction-1`.
318
+ *
319
+ * An empty `id=""` is skipped: a browser treats it as no id at all, and
320
+ * reserving `''` would consume the slugger's empty-slug fallback and leave a
321
+ * punctuation-only heading with `id="-1"` instead of `section`.
322
+ *
323
+ * ponytail: satteri delivers raw HTML as *per-tag fragments* when the block
324
+ * spans lines — `<xmp>`, `<a id="x">`, `</a>`, `</xmp>` are four separate raw
325
+ * nodes — so a nested container's context is not visible here and `nonTextTags`
326
+ * cannot see it. The residue is one shape: an `<a id>`, `<li id>`, or heading id
327
+ * nested inside a discarded container across several lines still reserves its
328
+ * slug, so a heading of the same text gets `-1`. That is a *conservative* miss —
329
+ * it can cost a heading its preferred anchor, never let a decoy steal one — and
330
+ * closing it means tracking open tags across raw nodes, which is a second HTML
331
+ * parser for a construct no published note contains. Revisit if a real note
332
+ * ever writes one.
333
+ */
334
+ function rawHtmlIds(html) {
335
+ const ids = [];
336
+ sanitizeHtml(html, {
337
+ allowedTags: [],
338
+ allowedAttributes: {},
339
+ nonTextTags: POLICY.nonTextTags,
340
+ transformTags: {
341
+ '*': (tagName, attribs) => {
342
+ const id = attribs['id'];
343
+ if (typeof id === 'string' && id !== '' && isIdBearing(tagName))
344
+ ids.push(id);
345
+ return { tagName, attribs };
346
+ },
347
+ },
348
+ });
349
+ return ids;
350
+ }
351
+ /**
352
+ * Stable anchor ids plus a visible, labelled link to each heading.
353
+ *
354
+ * Headings that already carry an id are generated structure (satteri's footnote
355
+ * label), not authored content: they keep their id and stay out of the heading
356
+ * tree.
357
+ *
358
+ * Raw HTML in the body reaches the tree as opaque `raw` nodes that the element
359
+ * visitor never sees, and `sanitize()` drops every id it did not generate. That
360
+ * drop happens too late on its own: a raw `<a id="introduction">` written before
361
+ * `## Introduction` would already have claimed the heading's anchor, leaving the
362
+ * deep link, the future table of contents entry, and the Pagefind anchor all
363
+ * resolving to the decoy. Reserving each raw id in this slugger *as it is
364
+ * encountered* is what makes generated ids disjoint from authored ones, so the
365
+ * heading gets a free id and the decoy is dropped with nothing to shadow.
366
+ *
367
+ * `pageTitle` resolves the one structural conflict between the artifact and
368
+ * requirements section 9.2: the producer writes the title into the body as a
369
+ * leading `# Title`, and the anatomy renders the title itself, above the
370
+ * article. See {@link RenderOptions.pageTitle}. When it is given, a leading
371
+ * `h1` whose text matches is removed — and nothing else changes, because every
372
+ * alternative to removal rearranges an outline the author wrote.
373
+ */
374
+ function headingPlugin(collected, pageTitle, label) {
375
+ const slugger = new Slugger();
376
+ let isFirstHeading = true;
377
+ return {
378
+ name: 'anc-headings',
379
+ // Raw HTML is visited in document order alongside elements, so an id
380
+ // written before a heading is reserved before that heading is slugged.
381
+ // `slug()` registers the value whether or not it was already taken, which
382
+ // is exactly the reservation wanted here.
383
+ raw(node) {
384
+ for (const id of rawHtmlIds(node.value)) {
385
+ collected.rawIds.set(id, (collected.rawIds.get(id) ?? 0) + 1);
386
+ slugger.slug(id);
387
+ }
388
+ },
389
+ element: {
390
+ filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
391
+ visit(node, ctx) {
392
+ if (typeof node.properties?.['id'] === 'string')
393
+ return;
394
+ const text = ctx.textContent(node);
395
+ const wasFirst = isFirstHeading;
396
+ isFirstHeading = false;
397
+ // The body's own restatement of the title the page already renders.
398
+ // Removed before an id is minted, so it never enters `headings` and the
399
+ // table of contents cannot carry an entry whose anchor is not on the
400
+ // page. Nothing is reserved in the slugger either: a later heading
401
+ // genuinely titled the same thing gets the clean id.
402
+ if (pageTitle !== undefined && wasFirst && node.tagName === 'h1' && text.trim() === pageTitle.trim()) {
403
+ ctx.removeNode(node);
404
+ return;
405
+ }
406
+ // Text that is only punctuation or invisibles slugs to "", which would
407
+ // give several headings the same empty id and a useless `href="#"`.
408
+ // The fallback is re-slugged rather than used literally so that it is
409
+ // registered as taken: otherwise a later heading actually titled
410
+ // "Section" would collide with it.
411
+ const id = slugger.slug(text.trim()) || slugger.slug('section');
412
+ collected.headings.push({ depth: Number.parseInt(node.tagName[1] ?? '1', 10), id, text });
413
+ ctx.setProperty(node, 'id', id);
414
+ ctx.appendChild(node, {
415
+ type: 'element',
416
+ tagName: 'a',
417
+ properties: {
418
+ className: ['heading-anchor'],
419
+ href: `#${id}`,
420
+ // Trimmed, as the id on the line above already is. A heading
421
+ // carrying an image, a raw tag, or an HTML comment yields text with
422
+ // leading or trailing whitespace — `## Status <!-- note -->` gives
423
+ // `"Status "` — and an accessible name ending in a space is a name
424
+ // a screen reader reads with a pause nobody wrote. Both shapes are
425
+ // valid Markdown that neither corpus happens to contain.
426
+ 'aria-label': label(text.trim()),
427
+ },
428
+ children: [{ type: 'text', value: '#' }],
429
+ });
430
+ },
431
+ },
432
+ };
433
+ }
434
+ /**
435
+ * Obsidian callouts (`> [!note] Title`) become a labelled blockquote.
436
+ *
437
+ * The marker is stripped from the rendered text and the title, if any, is
438
+ * promoted to a `<strong>`; a callout without a title keeps only its kind. The
439
+ * kind lands on `data-callout` so TK-02 can style it without parsing classes,
440
+ * and it is lowercased and length-bounded so body content cannot use it as an
441
+ * unbounded attribute channel.
442
+ */
443
+ function calloutPlugin() {
444
+ return {
445
+ name: 'anc-callouts',
446
+ element: {
447
+ filter: ['blockquote'],
448
+ visit(node) {
449
+ const children = [...(node.children ?? [])];
450
+ const index = children.findIndex((child) => child.type === 'element' && child.tagName === 'p');
451
+ const paragraph = index === -1 ? undefined : children[index];
452
+ if (!paragraph || paragraph.type !== 'element')
453
+ return;
454
+ const [lead, ...rest] = paragraph.children ?? [];
455
+ if (!lead || lead.type !== 'text')
456
+ return;
457
+ // The optional [+-] is Obsidian's fold marker; it has no static meaning.
458
+ const marker = /^\[!([A-Za-z][A-Za-z0-9-]{0,23})\][+-]?[ \t]*/.exec(lead.value);
459
+ if (!marker)
460
+ return;
461
+ const kind = marker[1].toLowerCase();
462
+ const remainder = lead.value.slice(marker[0].length);
463
+ const breakAt = remainder.indexOf('\n');
464
+ const title = breakAt === -1 ? remainder : remainder.slice(0, breakAt);
465
+ const body = breakAt === -1 ? '' : remainder.slice(breakAt + 1);
466
+ children[index] = {
467
+ type: 'element',
468
+ tagName: 'p',
469
+ properties: {},
470
+ children: [
471
+ ...(title
472
+ ? [
473
+ {
474
+ type: 'element',
475
+ tagName: 'strong',
476
+ properties: { className: ['callout-title'] },
477
+ children: [{ type: 'text', value: title }],
478
+ },
479
+ { type: 'text', value: '\n' },
480
+ ]
481
+ : []),
482
+ ...(body ? [{ type: 'text', value: body }] : []),
483
+ ...rest,
484
+ ],
485
+ };
486
+ return {
487
+ type: 'element',
488
+ tagName: 'blockquote',
489
+ properties: { className: ['callout', `callout-${kind}`], 'data-callout': kind },
490
+ children,
491
+ };
492
+ },
493
+ },
494
+ };
495
+ }
496
+ /**
497
+ * Code fences become `figure > pre > code` with build-time highlighting; math
498
+ * and Mermaid fences become rendered content instead.
499
+ *
500
+ * Copy controls are not serialized into article HTML: `code-copy.ts` inserts
501
+ * them only when scripting runs, so Pagefind never indexes their chrome and a
502
+ * no-script reader meets no dead button.
503
+ *
504
+ * Math reaches this visitor too — satteri renders `$$…$$` as
505
+ * `pre > code.language-math` with no fence language — and is handled by
506
+ * `mathPlugin` rather than here, so it never acquires a highlighting shell.
507
+ */
508
+ function codePlugin(collected, diagrams, caption) {
509
+ return {
510
+ name: 'anc-code',
511
+ element: [
512
+ {
513
+ filter: ['pre'],
514
+ async visit(node, ctx) {
515
+ const code = node.children?.find((child) => child.type === 'element' && child.tagName === 'code');
516
+ if (!code || code.type !== 'element')
517
+ return;
518
+ if (asClassList(code.properties?.['className']).includes(`language-${MATH_LANGUAGE}`))
519
+ return;
520
+ // An unlabelled fence has no `data.lang`. It is still a code block and
521
+ // still gets the same shell, so a reader can copy it.
522
+ const language = fenceLanguage(code.data) ?? 'plaintext';
523
+ const source = ctx.textContent(code).replace(/\n$/, '');
524
+ if (language === MERMAID_LANGUAGE) {
525
+ collected.hasMermaid = true;
526
+ return diagramFigure(source, diagrams, caption);
527
+ }
528
+ collected.hasCode = true;
529
+ return {
530
+ type: 'element',
531
+ tagName: 'figure',
532
+ properties: {
533
+ className: ['code-block'],
534
+ 'data-code-language': language,
535
+ },
536
+ children: [
537
+ {
538
+ type: 'element',
539
+ tagName: 'pre',
540
+ properties: {},
541
+ children: [
542
+ {
543
+ type: 'element',
544
+ tagName: 'code',
545
+ properties: { className: [`language-${language}`] },
546
+ children: [{ type: 'raw', value: await highlight(source, language) }],
547
+ },
548
+ ],
549
+ },
550
+ ],
551
+ };
552
+ },
553
+ },
554
+ ],
555
+ };
556
+ }
557
+ /**
558
+ * A `<figure>` holding one diagram, in whichever form the mode calls for.
559
+ *
560
+ * The two modes differ only inside the figure; the figure, its caption, and its
561
+ * accessible name are the same either way, so a reader meets the same structure
562
+ * and the page's outline does not depend on a build flag.
563
+ *
564
+ * **Build-time mode** emits a placeholder that {@link substituteDiagrams} fills
565
+ * with rendered SVG after sanitization. The SVG cannot go through the sanitizer
566
+ * — its allowlist has no `svg`, and widening it to admit one would admit every
567
+ * raw-HTML `<svg>` a note body cared to write, which is precisely the hole
568
+ * §15.2 closes. So the diagram is rendered by a module that has already
569
+ * sanitized it (`mermaid-render.ts` strips scripts, handlers, styles, and
570
+ * external references, and fails the build rather than passing one through) and
571
+ * is spliced in afterwards, at a marker the sanitizer itself produced.
572
+ *
573
+ * **Client mode** ships the diagram source as escaped text inside a `<pre>`,
574
+ * which `src/scripts/diagram.ts` reads and replaces. That is also the
575
+ * no-JavaScript rendering: a reader without scripting sees the diagram's source,
576
+ * which is legible and honest, rather than an empty box.
577
+ */
578
+ function diagramFigure(source, diagrams, format) {
579
+ const caption = diagramCaption(source, format);
580
+ if (DIAGRAM_MODE === 'build-time') {
581
+ const token = `${DIAGRAM_TOKEN_PREFIX}${diagrams.length}${DIAGRAM_TOKEN_SUFFIX}`;
582
+ diagrams.push({ source, token });
583
+ return {
584
+ type: 'element',
585
+ tagName: 'figure',
586
+ properties: { className: ['diagram'], 'data-diagram': MERMAID_LANGUAGE },
587
+ children: [
588
+ // A `div` rather than the bare token, so the marker has an element to
589
+ // be a child of and cannot end up adjacent to the caption text.
590
+ // The scroll container is a keyboard stop, for exactly the reason a
591
+ // `<pre>` is: a diagram wider than the measure scrolls horizontally at
592
+ // 320 px, and a scrollable region must be reachable by keyboard (axe
593
+ // `scrollable-region-focusable`, WCAG 2.1.1). Display math gets the
594
+ // same treatment a few lines up.
595
+ {
596
+ type: 'element',
597
+ tagName: 'div',
598
+ properties: { className: ['diagram-canvas'], tabindex: '0' },
599
+ children: [{ type: 'text', value: token }],
600
+ },
601
+ { type: 'element', tagName: 'figcaption', properties: {}, children: [{ type: 'text', value: caption }] },
602
+ ],
603
+ };
604
+ }
605
+ const clientToken = `${DIAGRAM_TOKEN_PREFIX}${diagrams.length}${DIAGRAM_TOKEN_SUFFIX}`;
606
+ diagrams.push({ source, token: clientToken, sourceOnly: true, caption });
607
+ return {
608
+ // **Client mode routes through the token too**, for the same reason the math
609
+ // client branch does: {@link RENDERED_MARKER} is minted after the sanitizer,
610
+ // and this node is built before it. A first version returned the figure
611
+ // directly and pushed no request, so client-mode diagrams were never marked
612
+ // — measured with `DIAGRAM_MODE='client'`, a diagram page earned no
613
+ // allowance and would have been held to the base budget while shipping
614
+ // ~232 KB of Mermaid. Not minting is the safe direction for the residue scan
615
+ // and the *wrong* one for the budget, which is why this path cannot simply
616
+ // be skipped.
617
+ type: 'element',
618
+ tagName: 'figure',
619
+ properties: { className: ['diagram'], 'data-diagram': MERMAID_LANGUAGE },
620
+ children: [{ type: 'text', value: clientToken }],
621
+ };
622
+ }
623
+ /**
624
+ * The diagram's accessible name, taken from its own source.
625
+ *
626
+ * A diagram is content, not decoration, so it needs a name — and axe reports an
627
+ * unnamed `role="graphics-document"` as a violation. The name has to come from
628
+ * somewhere the author controls, and Mermaid's own `title` directive (`title:`
629
+ * in front matter, `pie title X`, `gantt title X`) is the only such field. When
630
+ * the diagram declares one it is used verbatim; when it does not, the diagram's
631
+ * *kind* is the honest fallback — "Flowchart diagram" says what the reader is
632
+ * looking at without inventing a description of content this renderer cannot
633
+ * summarize.
634
+ *
635
+ * Deliberately not a fixed literal such as "Diagram": a page with three of them
636
+ * would give a screen reader three identically named figures, which is the same
637
+ * defect as an unnamed one.
638
+ */
639
+ function diagramCaption(source, caption) {
640
+ const declared = /^\s*(?:---[\s\S]*?\btitle:\s*(.+?)$|(?:pie|gantt|journey|xychart-beta|quadrantChart|radar-beta)\s+title\s+(.+?)$)/m.exec(source);
641
+ const title = (declared?.[1] ?? declared?.[2])?.trim().replace(/^["']|["']$/g, '');
642
+ if (title !== undefined && title !== '')
643
+ return boundLabel(title);
644
+ const kind = /^\s*(?:---[\s\S]*?---\s*)?([A-Za-z][\w-]*)/.exec(source)?.[1] ?? 'Mermaid';
645
+ return caption(DIAGRAM_KIND_NAMES[kind.toLowerCase()] ?? kind);
646
+ }
647
+ /**
648
+ * Readable names for the fence keywords a reader would not recognize.
649
+ *
650
+ * Only the ones whose keyword is not already the English word: `sequenceDiagram`
651
+ * reads as "sequenceDiagram diagram" without help, and `graph` means flowchart.
652
+ * A keyword absent here is used as written, so a new Mermaid diagram type gets a
653
+ * serviceable name rather than none.
654
+ *
655
+ * ponytail: these names are English, so an untitled diagram on a Chinese page
656
+ * reads "Flowchart 图示" — the noun follows the document and the kind does not.
657
+ * Deliberate, and the smaller wrong of the two available: the alternative is a
658
+ * per-locale table of Mermaid diagram kinds, and a reader who meets a Mermaid
659
+ * diagram is more likely to recognize "Flowchart" than a translation of it. A
660
+ * diagram that declares its own `title` bypasses this entirely and is the
661
+ * authored path. Move these into `Translation` if a corpus ever ships untitled
662
+ * diagrams on Chinese pages at any volume.
663
+ */
664
+ const DIAGRAM_KIND_NAMES = {
665
+ graph: 'Flowchart',
666
+ flowchart: 'Flowchart',
667
+ 'flowchart-v2': 'Flowchart',
668
+ sequencediagram: 'Sequence',
669
+ classdiagram: 'Class',
670
+ 'statediagram-v2': 'State',
671
+ statediagram: 'State',
672
+ erdiagram: 'Entity-relationship',
673
+ journey: 'User journey',
674
+ gitgraph: 'Git graph',
675
+ 'xychart-beta': 'XY chart',
676
+ 'sankey-beta': 'Sankey',
677
+ 'block-beta': 'Block',
678
+ 'packet-beta': 'Packet',
679
+ 'architecture-beta': 'Architecture',
680
+ 'treemap-beta': 'Treemap',
681
+ 'radar-beta': 'Radar',
682
+ quadrantchart: 'Quadrant',
683
+ requirementdiagram: 'Requirement',
684
+ c4context: 'C4 context',
685
+ mindmap: 'Mind map',
686
+ kanban: 'Kanban',
687
+ timeline: 'Timeline',
688
+ gantt: 'Gantt',
689
+ pie: 'Pie',
690
+ };
691
+ /**
692
+ * Math becomes MathML, replacing the `pre > code.language-math` shell satteri
693
+ * emits.
694
+ *
695
+ * The MathML is spliced in after sanitization for the same reason the diagram
696
+ * SVG is: `math` is on the sanitizer's `nonTextTags` list, so admitting it
697
+ * through the allowlist would admit every raw-HTML `<math>` element a note body
698
+ * wrote. Temml's output is generated from a parsed TeX tree by a renderer that
699
+ * emits a closed set of elements and attributes, which is a different and much
700
+ * narrower trust question than "any MathML in the body".
701
+ */
702
+ function mathPlugin(collected, maths) {
703
+ return {
704
+ name: 'anc-math',
705
+ element: {
706
+ filter: ['code'],
707
+ visit(node, ctx) {
708
+ const classes = asClassList(node.properties?.['className']);
709
+ if (!classes.includes(`language-${MATH_LANGUAGE}`))
710
+ return;
711
+ collected.hasMath = true;
712
+ const isDisplay = classes.includes('math-display');
713
+ // Display math replaces the whole `pre`; inline math replaces the
714
+ // `code`. Leaving the `pre` in place around a `<math display="block">`
715
+ // would put a scrollable preformatted box around an element that lays
716
+ // itself out, and would announce a preformatted-text region a reader
717
+ // then has to step through to reach the expression.
718
+ //
719
+ // Display math keeps the `tabindex` the `pre` would have carried, and
720
+ // for the same reason: a long derivation does not wrap, so the wrapper
721
+ // scrolls horizontally, and a scrollable region must be reachable by
722
+ // keyboard (axe `scrollable-region-focusable`, WCAG 2.1.1). Inline math
723
+ // sits in the text flow and never scrolls, so it gets none.
724
+ const parent = ctx.parent(node);
725
+ const target = parent?.type === 'element' && parent.tagName === 'pre' ? parent : node;
726
+ if (MATH_MODE === 'client') {
727
+ // **The source is still validated at build time**, and that is not
728
+ // optional. `renderMath` refuses `\textcolor`, `\color`, `\colorbox`,
729
+ // `\fcolorbox` and `\pagecolor`, and then re-checks the *rendered*
730
+ // output for any colour attribute whatever — `src/lib/math.ts` calls
731
+ // that check total, and it is only total if it runs.
732
+ //
733
+ // A first version of this branch returned before that call, and the
734
+ // consequence was measured rather than theorised: `\colorbox{yellow}`
735
+ // failed the build in build-time mode and, through the client branch,
736
+ // shipped to the browser where the runtime rendered
737
+ // `<mrow mathbackground="#FFFF00">` — the 1.1:1 yellow on the light
738
+ // palette that whole rejection exists to prevent. Client rendering
739
+ // moves *where* the MathML is produced; it does not move what an
740
+ // author is permitted to write.
741
+ //
742
+ // The result is discarded, because the TeX source is what ships here.
743
+ // The call is made for its refusals, which throw. That is a real cost
744
+ // stated plainly: every expression is rendered once at build time to
745
+ // validate it, and again in each reader's browser to display it.
746
+ renderMath(ctx.textContent(node), isDisplay);
747
+ // **The TeX source, as a hast text node**, which is what a JS-disabled
748
+ // reader sees and what the runtime reads back. Not a token: a token is
749
+ // needed only because MathML does not round-trip through the
750
+ // serializer and the sanitizer, and a text node does — the serializer
751
+ // escapes it, so `<` and `&` in an expression are safe with no work
752
+ // here. `diagramFigure`'s client branch does the same for the same
753
+ // reason.
754
+ //
755
+ // **The carrier is a `span`, not the `pre` a fence would suggest**,
756
+ // and that is forced rather than chosen: a `<pre>` inside a `<p>` is
757
+ // torn out of the paragraph by the parser — measured,
758
+ // `Text <pre>x</pre> more` becomes `<p>Text </p><pre>x</pre> more<p></p>`
759
+ // — so inline math in a `<pre>` would break every paragraph carrying
760
+ // one. Using the same element for both keeps one shape for the runtime
761
+ // to find and keeps the display case's `tabindex` where it already is.
762
+ //
763
+ // The class is `math-inline`/`math-display` rather than a new
764
+ // `math-source`, because `allowedClasses.pre` and `allowedClasses.code`
765
+ // do not carry one — measured: `<pre class="math-source">` comes back
766
+ // with no class at all. A fix keyed on a class the sanitizer strips
767
+ // compiles, reads correctly, and does nothing.
768
+ // **Keyed by the element pair, not by a `data-` attribute.** The
769
+ // diagram path carries `data-diagram` because its `figure` already had
770
+ // an attribute grant; a `span` does not, and measured, a `data-math`
771
+ // written here comes back stripped — the same silent-strip trap as a
772
+ // class the allowlist does not carry. `span.math-display >
773
+ // code.language-math` identifies client-mode math on its own, because
774
+ // build-time mode puts MathML inside that span rather than a `code`.
775
+ // Widening a security allowlist to restate what a selector already
776
+ // gives would be the wrong trade.
777
+ //
778
+ // **The unforgeable marker takes the same route the MathML does**, and
779
+ // for the same reason: it is minted at {@link substituteRendered},
780
+ // after the sanitizer. So this branch emits a token here and the
781
+ // marked `<code>` is substituted in later — see
782
+ // {@link RENDERED_MARKER}. Writing the attribute directly on this node
783
+ // would put it in front of the sanitizer, which strips it (measured),
784
+ // and adding it to the allowlist to make it survive would make an
785
+ // author's copy survive too.
786
+ const clientToken = `${MATH_TOKEN_PREFIX}${maths.length}${MATH_TOKEN_SUFFIX}`;
787
+ maths.push({ tex: ctx.textContent(node), isDisplay, token: clientToken, source: true });
788
+ ctx.replaceNode(target, {
789
+ type: 'element',
790
+ tagName: 'span',
791
+ properties: {
792
+ className: [isDisplay ? 'math-display' : 'math-inline'],
793
+ ...(isDisplay ? { tabindex: '0' } : {}),
794
+ },
795
+ children: [{ type: 'text', value: clientToken }],
796
+ });
797
+ return;
798
+ }
799
+ const token = `${MATH_TOKEN_PREFIX}${maths.length}${MATH_TOKEN_SUFFIX}`;
800
+ maths.push({ tex: ctx.textContent(node), isDisplay, token });
801
+ ctx.replaceNode(target, {
802
+ type: 'element',
803
+ tagName: 'span',
804
+ properties: {
805
+ className: [isDisplay ? 'math-display' : 'math-inline'],
806
+ ...(isDisplay ? { tabindex: '0' } : {}),
807
+ },
808
+ children: [{ type: 'text', value: token }],
809
+ });
810
+ },
811
+ },
812
+ };
813
+ }
814
+ /**
815
+ * A task-list checkbox is named by the text of its own list item.
816
+ *
817
+ * axe reports an unnamed checkbox as a `label` violation at critical impact —
818
+ * four nodes on the fixture corpus — and it is right: a screen reader
819
+ * encountering the input alone announces "checkbox, checked" with nothing
820
+ * saying what is checked.
821
+ *
822
+ * The name is lifted from the item's text rather than being a fixed string.
823
+ * Both alternatives are worse. A literal such as `"Task"` names four checkboxes
824
+ * identically, so it silences the tool without helping a reader, and it would
825
+ * be an English chrome string minted in the renderer — outside the components
826
+ * TK-16's bilingual sweep covers, so a zh-CN note would announce it in English.
827
+ * The item's own text is content, so it is already in the document's language
828
+ * and it is already distinct per item.
829
+ *
830
+ * The value is bounded and whitespace-collapsed: an `aria-label` is announced
831
+ * in full with no structure, so a task item carrying a paragraph would be read
832
+ * as one unbroken run before the reader learns whether it is checked. Past the
833
+ * bound the name is truncated and the full text still follows in the item
834
+ * itself, which is where a reader gets it either way.
835
+ */
836
+ const TASK_LABEL_LIMIT = 120;
837
+ /** One line, collapsed and length-bounded, suitable as an `aria-label`. */
838
+ function boundLabel(text) {
839
+ const collapsed = text.replace(/\s+/g, ' ').trim();
840
+ // Split on code points, not UTF-16 units: `slice` on a surrogate pair leaves
841
+ // half a character, which renders as a replacement glyph and is announced as
842
+ // one. The limit is a readability bound, so counting characters is also the
843
+ // honest unit for it.
844
+ const characters = [...collapsed];
845
+ return characters.length > TASK_LABEL_LIMIT
846
+ ? `${characters.slice(0, TASK_LABEL_LIMIT).join('')}…`
847
+ : collapsed;
848
+ }
849
+ /**
850
+ * One task item's own text and its own checkbox, excluding any nested list.
851
+ *
852
+ * Both halves have to skip the same subtree, and both are defects otherwise:
853
+ *
854
+ * - `ctx.textContent(li)` on a parent item returns its children's text too, so
855
+ * `- [ ] Parent` with two sub-tasks names the parent checkbox
856
+ * "Parent Child A Child B" — a screen reader reads the whole subtree as the
857
+ * parent's name and then reads each child again.
858
+ * - the checkbox is not always a direct child. A *loose* list — one with a
859
+ * blank line between items — wraps each item's content in a `<p>`, so a
860
+ * direct-children search finds nothing and the item silently keeps the
861
+ * unnamed checkbox this plugin exists to name.
862
+ *
863
+ * The walk therefore descends through wrappers and stops at `ul`/`ol`, which is
864
+ * exactly the boundary between "this item" and "the items under it".
865
+ */
866
+ function taskItemContent(node) {
867
+ let checkbox;
868
+ let text = '';
869
+ const walk = (children) => {
870
+ for (const child of children) {
871
+ const item = child;
872
+ if (item.type === 'text') {
873
+ text += item.value ?? '';
874
+ continue;
875
+ }
876
+ if (item.type !== 'element')
877
+ continue;
878
+ if (item.tagName === 'ul' || item.tagName === 'ol')
879
+ continue;
880
+ if (item.tagName === 'input') {
881
+ checkbox ??= item;
882
+ continue;
883
+ }
884
+ if (item.children !== undefined)
885
+ walk(item.children);
886
+ }
887
+ };
888
+ walk(node.children ?? []);
889
+ return { checkbox, text };
890
+ }
891
+ function taskListPlugin() {
892
+ return {
893
+ name: 'anc-task-lists',
894
+ element: {
895
+ filter: ['li'],
896
+ visit(node, ctx) {
897
+ if (!asClassList(node.properties?.['className']).includes('task-list-item'))
898
+ return;
899
+ const { checkbox, text } = taskItemContent(node);
900
+ if (checkbox === undefined)
901
+ return;
902
+ const label = boundLabel(text);
903
+ if (label === '')
904
+ return;
905
+ ctx.setProperty(checkbox, 'aria-label', label);
906
+ },
907
+ },
908
+ };
909
+ }
910
+ /**
911
+ * Table alignment moves from an inline `style` to `data-align`.
912
+ *
913
+ * satteri emits GFM column alignment as `style="text-align: left"`, which
914
+ * `style-src 'self'` blocks and the sanitizer drops anyway — losing the
915
+ * alignment the author asked for. A data attribute survives both.
916
+ */
917
+ function tablePlugin() {
918
+ return {
919
+ name: 'anc-tables',
920
+ element: {
921
+ filter: ['th', 'td'],
922
+ visit(node, ctx) {
923
+ const style = node.properties?.['style'];
924
+ if (typeof style !== 'string')
925
+ return;
926
+ const align = /text-align:\s*(left|center|right)/i.exec(style);
927
+ ctx.setProperty(node, 'style', null);
928
+ if (align)
929
+ ctx.setProperty(node, 'data-align', align[1].toLowerCase());
930
+ },
931
+ },
932
+ };
933
+ }
934
+ /** hast stores `class` as a string or an array; normalize before inspecting. */
935
+ function asClassList(value) {
936
+ if (Array.isArray(value))
937
+ return value.filter((item) => typeof item === 'string');
938
+ return typeof value === 'string' ? value.split(/\s+/) : [];
939
+ }
940
+ /**
941
+ * The fence info string satteri records on a code node, or `undefined` for an
942
+ * unlabelled fence. hast's `ElementData` is an open record with no declared
943
+ * `lang`, so the read is narrowed here rather than asserted at the call site.
944
+ */
945
+ function fenceLanguage(data) {
946
+ if (typeof data !== 'object' || data === null)
947
+ return undefined;
948
+ const lang = data['lang'];
949
+ return typeof lang === 'string' ? lang : undefined;
950
+ }
951
+ /**
952
+ * Explicit sanitization allowlist, replacing `sanitize-html`'s permissive
953
+ * defaults.
954
+ *
955
+ * Deliberately absent, per requirements section 15.2: `script`, `style`,
956
+ * `iframe`, `object`, `embed`, `form`, `svg`, `math`, `base`, `link`, `meta`,
957
+ * and every media element. Nothing grants a `style` attribute or any `on*`
958
+ * handler — an explicit allowlist excludes them by construction rather than by
959
+ * blocklist, so a new handler attribute cannot appear by omission.
960
+ *
961
+ * `allowedSchemesAppliedToAttributes` is deliberately left at the library
962
+ * default, which covers far more URL sinks (`action`, `formaction`, `poster`,
963
+ * `xlink:href`, …) than the three attributes this allowlist grants. Narrowing
964
+ * it would only remove protection.
965
+ */
966
+ const POLICY = {
967
+ /**
968
+ * satteri and this module's own plugins emit only: `a blockquote code del em
969
+ * h1`–`h6 hr img input li ol p pre section strong sup table tbody td th thead
970
+ * tr ul`, plus `figure` and `span` from the code-block transform.
971
+ *
972
+ * The remainder are reachable only through raw HTML in a note body. They are
973
+ * kept because requirements 15.1 promises a "safe HTML subset", and because a
974
+ * bilingual technical corpus has real uses for them — `ruby`/`rt`/`rp` for
975
+ * CJK annotation, `bdi`/`bdo` for bidirectional text, `wbr` for the long
976
+ * unbroken tokens section 16 calls out, `kbd`/`samp`/`var` for technical
977
+ * prose. Every one of them is inert: none is granted an attribute below
978
+ * beyond the few named there, so none can carry a URL, a handler, or a style.
979
+ *
980
+ * `button` is deliberately absent. Nothing this pipeline emits is a button,
981
+ * and a note body has no use for a control that cannot be wired to anything:
982
+ * the reader would meet a dead affordance. A raw-HTML `<button>` keeps its
983
+ * text and loses the element.
984
+ */
985
+ allowedTags: [
986
+ 'p', 'br', 'hr',
987
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
988
+ 'strong', 'b', 'em', 'i', 'u', 'del', 's', 'ins', 'mark', 'sup', 'sub',
989
+ 'abbr', 'small', 'span', 'q', 'cite', 'kbd', 'samp', 'var', 'time',
990
+ 'bdi', 'bdo', 'wbr', 'ruby', 'rt', 'rp', 'dfn',
991
+ 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
992
+ 'blockquote', 'pre', 'code', 'figure', 'figcaption', 'section', 'div',
993
+ 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
994
+ 'a', 'img', 'input',
995
+ ],
996
+ allowedAttributes: {
997
+ a: [
998
+ 'href', 'title', 'class', 'rel', 'aria-label', 'aria-describedby',
999
+ 'data-footnote-ref', 'data-footnote-backref', 'id',
1000
+ ],
1001
+ img: ['src', 'alt', 'title'],
1002
+ h1: ['id'],
1003
+ h2: ['id', 'class'],
1004
+ h3: ['id'],
1005
+ h4: ['id'],
1006
+ h5: ['id'],
1007
+ h6: ['id'],
1008
+ code: ['class'],
1009
+ pre: ['tabindex', 'class'],
1010
+ // `tabindex` for the display-math wrapper, which scrolls. Pinned to `0` by
1011
+ // the `span` transform below, so a raw-HTML span cannot claim a tab order.
1012
+ span: ['class', 'tabindex'],
1013
+ li: ['id', 'class'],
1014
+ ul: ['class'],
1015
+ ol: ['class', 'start'],
1016
+ section: ['class', 'data-footnotes'],
1017
+ blockquote: ['class', 'cite', 'data-callout'],
1018
+ figure: ['class', 'data-code-language', 'data-diagram'],
1019
+ figcaption: ['class'],
1020
+ div: ['class', 'tabindex'],
1021
+ input: ['type', 'checked', 'disabled', 'aria-label'],
1022
+ th: ['colspan', 'rowspan', 'scope', 'data-align'],
1023
+ td: ['colspan', 'rowspan', 'data-align'],
1024
+ col: ['span'],
1025
+ colgroup: ['span'],
1026
+ time: ['datetime'],
1027
+ abbr: ['title'],
1028
+ dfn: ['title'],
1029
+ bdo: ['dir'],
1030
+ q: ['cite'],
1031
+ },
1032
+ /**
1033
+ * `class` is a styling channel into the site's own stylesheet, so only the
1034
+ * classes this pipeline emits are allowed through. Every entry is a closed
1035
+ * literal or a namespaced prefix — no open pattern that would admit an
1036
+ * arbitrary lowercase class such as `site-header` and let body content
1037
+ * overlay page chrome. Any tag granted `class` above must appear here, or
1038
+ * `sanitize-html` passes its class value through unfiltered.
1039
+ */
1040
+ allowedClasses: {
1041
+ // `data-footnote-backref` is genuinely both: satteri emits it as an
1042
+ // attribute *and* as a class value on the same anchor.
1043
+ a: ['heading-anchor', 'data-footnote-backref'],
1044
+ code: ['language-*'],
1045
+ // Namespaced by `namespaceTokenClasses`, so this stays closed.
1046
+ span: ['token', 'token-*', 'math-inline', 'math-display'],
1047
+ h2: ['sr-only'],
1048
+ li: ['task-list-item'],
1049
+ ul: ['contains-task-list'],
1050
+ ol: [],
1051
+ section: ['footnotes'],
1052
+ blockquote: ['callout', 'callout-*'],
1053
+ // `diagram` is the figure a rendered diagram sits in; `code-block` the one a
1054
+ // fence sits in. `diagram-canvas` and `diagram-source` are the two shapes a
1055
+ // diagram takes, one per mode.
1056
+ figure: ['code-block', 'diagram'],
1057
+ figcaption: [],
1058
+ div: ['diagram-canvas'],
1059
+ pre: ['diagram-source'],
1060
+ strong: ['callout-title'],
1061
+ },
1062
+ allowedSchemes: ['http', 'https', 'mailto'],
1063
+ /**
1064
+ * `data:` is granted to `img[src]` alone so an inline raster image survives,
1065
+ * matching what TK-01's scanner already permits in the artifact and what
1066
+ * requirements 19.3 sets as `img-src 'self' data: https:`. The scheme grant is
1067
+ * necessary but not sufficient: the `img` transform additionally requires the
1068
+ * URI to be a base64 raster type, so `data:image/svg+xml` and `data:text/html`
1069
+ * never reach this check with a payload intact.
1070
+ */
1071
+ allowedSchemesByTag: { img: ['http', 'https', 'data'] },
1072
+ allowProtocolRelative: false,
1073
+ /**
1074
+ * Without this, a disallowed container leaks its text: `<iframe>secret
1075
+ * </iframe>` renders as `secret`. Discarding the subtree is the honest
1076
+ * behavior for a construct the policy rejects outright.
1077
+ */
1078
+ nonTextTags: [
1079
+ 'script', 'style', 'textarea', 'option', 'noscript', 'template',
1080
+ 'iframe', 'object', 'embed', 'form', 'svg', 'math', 'title',
1081
+ ],
1082
+ };
1083
+ /**
1084
+ * The rendering policy's terminal step. Transforms that must observe *every*
1085
+ * anchor and image live here rather than in a hast plugin, because raw HTML in
1086
+ * the Markdown body reaches the hast tree as opaque `raw` nodes that element
1087
+ * visitors never see — a link rewriter upstream would silently skip
1088
+ * `<a href="/other/">` written as raw HTML. `sanitize-html` parses the final
1089
+ * HTML, so it sees all of them, and its allowlist runs after `transformTags`.
1090
+ */
1091
+ function sanitize(html, routeForSlug, generatedIds, rawIds) {
1092
+ // Each generated id survives exactly once. `headingPlugin` has already made
1093
+ // the generated set disjoint from every raw-HTML id, so this no longer
1094
+ // arbitrates a collision — it only stops a raw duplicate of a generated id
1095
+ // (`&#105;ntroduction` for `introduction`, say) from being emitted twice.
1096
+ const unusedIds = new Set(generatedIds);
1097
+ const usedFootnoteIds = new Set();
1098
+ // One deny token per raw-HTML claim on that id; see the note below.
1099
+ const denials = new Map(rawIds);
1100
+ return sanitizeHtml(html, {
1101
+ ...POLICY,
1102
+ transformTags: {
1103
+ // Element ids are page-global names, and the layout owns hooks such as
1104
+ // `#search-toggle`. Only ids this pipeline generated survive, and each
1105
+ // survives exactly once: the heading anchors collected during rendering,
1106
+ // and satteri's own footnote ids.
1107
+ //
1108
+ // Restricted to tags the allowlist actually grants an `id`. On every other
1109
+ // tag the id is already dropped by `allowedAttributes` a moment later —
1110
+ // `tests/markdown.test.ts` proves that across every allowed tag — so this
1111
+ // is not the control that closes the channel, and running it there did
1112
+ // real harm: a raw `<div id="introduction">` consumed `introduction` from
1113
+ // `unusedIds` before the real heading was reached, and the heading shipped
1114
+ // with no id and an anchor pointing nowhere. `headingPlugin` reserves
1115
+ // against this same predicate, so the two passes agree on which ids exist.
1116
+ //
1117
+ // Raw-HTML claims are denied first, and by *count* rather than by
1118
+ // membership. Heading anchors need no such help — they are minted after
1119
+ // the raw node is seen, so they never collide — but satteri's footnote ids
1120
+ // are recognized by *shape*, so a raw `<a id="footnote-label">` would be
1121
+ // handed the id simply for appearing first, leaving the real footnote
1122
+ // heading without one and pointing the reference's `aria-describedby` at
1123
+ // the decoy. A count rather than a set because the raw claim and the
1124
+ // genuine element share the id string: denying the string outright would
1125
+ // take the id from the footnote too and leave `href="#user-content-fn-a"`
1126
+ // dangling. Spending one token per raw claim denies exactly the decoys.
1127
+ //
1128
+ // ponytail: document order decides which occurrence spends a token, so an
1129
+ // interleaving of decoy, real element, decoy would deny the real one. That
1130
+ // needs a note to write two raw ids straddling a generated one; the corpus
1131
+ // has zero raw ids and zero footnotes, and the bias is toward dropping an
1132
+ // id rather than granting a decoy. Revisit if raw HTML ever appears.
1133
+ '*': (tagName, attribs) => {
1134
+ const id = attribs['id'];
1135
+ if (id !== undefined && isIdBearing(tagName)) {
1136
+ const owed = denials.get(id) ?? 0;
1137
+ if (owed > 0)
1138
+ denials.set(id, owed - 1);
1139
+ else {
1140
+ if (unusedIds.delete(id))
1141
+ return { tagName, attribs };
1142
+ if (FOOTNOTE_ID.test(id) && !usedFootnoteIds.has(id)) {
1143
+ usedFootnoteIds.add(id);
1144
+ return { tagName, attribs };
1145
+ }
1146
+ }
1147
+ const { id: _dropped, ...rest } = attribs;
1148
+ return { tagName, attribs: rest };
1149
+ }
1150
+ return { tagName, attribs };
1151
+ },
1152
+ a: (tagName, attribs) => {
1153
+ const match = INTERNAL_HREF.exec(attribs['href'] ?? '');
1154
+ if (!match)
1155
+ return { tagName, attribs };
1156
+ const slug = slugFromSegment(match[1]);
1157
+ if (slug === undefined)
1158
+ return { tagName, attribs };
1159
+ const route = routeForSlug(slug);
1160
+ // An empty or absent route means "not a published slug"; leave the href
1161
+ // untouched so a dead link stays visible rather than becoming "/".
1162
+ if (!route)
1163
+ return { tagName, attribs };
1164
+ return { tagName, attribs: { ...attribs, href: `${route}${match[2] ?? ''}` } };
1165
+ },
1166
+ // Requirements section 15.1 requires explicit alt text. Missing alt means
1167
+ // decorative, which is `alt=""` — never an absent attribute, which makes
1168
+ // a screen reader announce the filename.
1169
+ //
1170
+ // `data:` is allowed on `img[src]` only (see `allowedSchemesByTag`), and
1171
+ // only for the base64 raster types TK-01's artifact scanner allowlists.
1172
+ // The scheme allowlist alone is too coarse: it would also admit
1173
+ // `data:image/svg+xml`, which can carry a script, and `data:text/html`.
1174
+ img: (tagName, attribs) => {
1175
+ const source = attribs['src'];
1176
+ const src = source !== undefined && /^data:/i.test(source) && !SAFE_IMAGE_DATA_URI.test(source)
1177
+ ? undefined
1178
+ : source;
1179
+ return {
1180
+ tagName,
1181
+ attribs: {
1182
+ ...attribs,
1183
+ ...(src === undefined ? { src: '' } : { src }),
1184
+ alt: typeof attribs['alt'] === 'string' ? attribs['alt'] : '',
1185
+ },
1186
+ };
1187
+ },
1188
+ // Task-list checkboxes are visual state, not controls. Forcing the shape
1189
+ // here also neutralizes any other raw-HTML input (a text field, a hidden
1190
+ // field) instead of relying on the tag allowlist alone. `checked` and
1191
+ // `disabled` are boolean attributes, so an empty value emits the bare
1192
+ // form; `sanitize-html` treats `hidden` as non-boolean and needs a value.
1193
+ //
1194
+ // `aria-label` is preserved when `taskListPlugin` set one — it is the
1195
+ // item's own text, and dropping it here would reinstate the axe `label`
1196
+ // violation this shape otherwise causes.
1197
+ //
1198
+ // Re-bounded rather than trusted. Raw HTML in a note body reaches this
1199
+ // transform too, and by the time the final HTML is parsed a label the
1200
+ // plugin wrote is indistinguishable from one a raw `<input>` carried — so
1201
+ // without this, body content has an unbounded attribute channel, which is
1202
+ // the same hazard `calloutPlugin` bounds its `data-callout` against. The
1203
+ // limit is `taskListPlugin`'s own, so the plugin's labels pass through
1204
+ // unchanged and only an over-long one is cut.
1205
+ input: (tagName, attribs) => {
1206
+ const label = attribs['aria-label'];
1207
+ return {
1208
+ tagName,
1209
+ attribs: {
1210
+ type: 'checkbox',
1211
+ disabled: '',
1212
+ ...(typeof label === 'string' && label !== ''
1213
+ ? { 'aria-label': boundLabel(label) }
1214
+ : {}),
1215
+ ...('checked' in attribs ? { checked: '' } : {}),
1216
+ },
1217
+ };
1218
+ },
1219
+ // A `<pre>` scrolls horizontally when a line is longer than the measure,
1220
+ // and a scrollable region must be reachable by keyboard — axe
1221
+ // `scrollable-region-focusable`, WCAG 2.1.1. `tabindex="0"` is the whole
1222
+ // fix: the browser then scrolls it with the arrow keys. No `role`, because
1223
+ // `<pre>` already conveys preformatted text, and no `aria-label`, which
1224
+ // would make a screen reader announce a name for every fence.
1225
+ //
1226
+ // Forced here rather than set in `codePlugin` so it reaches *every* `pre`:
1227
+ // the highlighted code fences, the client-mode diagram source, and any
1228
+ // raw-HTML one. Forcing also pins the value — a raw `tabindex="5"` would
1229
+ // otherwise hijack the document's tab order.
1230
+ pre: (tagName, attribs) => ({ tagName, attribs: { ...attribs, tabindex: '0' } }),
1231
+ // The display-math wrapper is the one `span` this pipeline gives a
1232
+ // `tabindex`, because it scrolls. Pinning the value here rather than
1233
+ // trusting the plugin closes the same channel `pre` closes above: by the
1234
+ // time the final HTML is parsed, a raw-HTML `<span tabindex="5">` is
1235
+ // indistinguishable from one the plugin wrote, and it would hijack the
1236
+ // document's tab order. A `span` with any other class keeps none.
1237
+ span: (tagName, attribs) => {
1238
+ const { tabindex: _dropped, ...rest } = attribs;
1239
+ const isScrollable = (attribs['class'] ?? '').split(/\s+/).includes('math-display');
1240
+ return { tagName, attribs: isScrollable ? { ...rest, tabindex: '0' } : rest };
1241
+ },
1242
+ // The diagram's scroll container, by the same rule and for the same
1243
+ // reason: it is the one `div` this pipeline gives a `tabindex`, and the
1244
+ // value is pinned here so a raw-HTML `<div tabindex="5">` cannot claim a
1245
+ // place in the document's tab order.
1246
+ div: (tagName, attribs) => {
1247
+ const { tabindex: _dropped, ...rest } = attribs;
1248
+ const isScrollable = (attribs['class'] ?? '').split(/\s+/).includes('diagram-canvas');
1249
+ return { tagName, attribs: isScrollable ? { ...rest, tabindex: '0' } : rest };
1250
+ },
1251
+ },
1252
+ });
1253
+ }
1254
+ /**
1255
+ * The attribute that says "this region was produced by a renderer, not typed by
1256
+ * an author", and the one place it may be written.
1257
+ *
1258
+ * ## The problem it closes
1259
+ *
1260
+ * Two consumers outside this module need to identify a rendered region in
1261
+ * `dist/`, and both keyed on something an author can forge:
1262
+ *
1263
+ * - `scripts/scan-residue.ts` exempts `absolute local path` inside a math region,
1264
+ * because real TeX writes `f:\mathbb{R}` — a letter, a colon, a backslash,
1265
+ * byte-identical to a Windows drive path. It keyed on `code.language-math`,
1266
+ * and **a note writing a fence labelled `language-math` produces exactly
1267
+ * that**: measured, a host path inside one goes FIRES → CLEAN.
1268
+ * - `tests/built-routes.test.ts` grants a per-page JavaScript allowance to a
1269
+ * page carrying a client-rendered construct, keyed on `class="math-inline"` —
1270
+ * which any body can write, minting itself 220 KB of budget.
1271
+ *
1272
+ * ## Why an allowlisted attribute cannot be the answer
1273
+ *
1274
+ * The obvious fix — a `data-` attribute on the rendered element — fails on a
1275
+ * measurement rather than on taste. The sanitizer's `allowedAttributes` is what
1276
+ * would make such an attribute *survive*, and it is exactly what would make an
1277
+ * author's copy survive too. Measured, on the two this repository already
1278
+ * allows: `<figure class="diagram" data-diagram="mermaid">forged</figure>`
1279
+ * written in a note body comes through the pipeline **intact**. Any attribute
1280
+ * added to that list inherits the same property the day it is added.
1281
+ *
1282
+ * ## Where it is minted instead
1283
+ *
1284
+ * Here, at {@link substituteRendered} — *after* `sanitize()` has run. An
1285
+ * author's bytes have already been through the allowlist by this point, so
1286
+ * nothing a body wrote can acquire this attribute: the only writer is the
1287
+ * replacement markup, and that markup is chosen by index from this render's own
1288
+ * list rather than by anything in the document.
1289
+ *
1290
+ * The forgery defence is the one the token mechanism already has, and it is
1291
+ * stronger than "the string is unusual": substitution is positional and
1292
+ * exhausting, and a body writing a token makes the build **throw** rather than
1293
+ * receive somebody else's render. Measured — all three shapes refuse:
1294
+ *
1295
+ * ```
1296
+ * Prose renderedMathPlaceholder0End more. -> a rendering placeholder survived substitution
1297
+ * renderedMathPlaceholder0End and $$x^2$$ here. -> appeared 2 times, expected exactly once
1298
+ * ```
1299
+ *
1300
+ * So this attribute needs no allowlist entry, and deliberately has none: it is
1301
+ * written after the only pass that could remove it.
1302
+ *
1303
+ * ## What it is not
1304
+ *
1305
+ * Not a security boundary on its own. It says "a renderer wrote this", which is
1306
+ * what both consumers actually need to know; it says nothing about the content
1307
+ * being safe, which is `renderMath` and `renderDiagram`'s own responsibility for
1308
+ * their own output.
1309
+ *
1310
+ * ## Two things measured the hard way
1311
+ *
1312
+ * **The name is `data-rendered`, not this project's name in an attribute.** A first
1313
+ * version carried this project's name, and `tests/site-identity.test.ts` went
1314
+ * red on it: every stranger's note containing an expression or a diagram shipped
1315
+ * the string into their own `dist/`, which is the headline TK-31 criterion. The
1316
+ * attribute has to be unforgeable, not branded.
1317
+ *
1318
+ * **A consumer must check the marker's *position*, not merely its presence in
1319
+ * the page.** The string has no character `escapeHtml` rewrites, so a note body
1320
+ * typing it in a paragraph puts it in the output as ordinary text — measured,
1321
+ * and also through numeric character references. That is fine for the residue
1322
+ * scan, which reads it out of a matched attribute region, and was a hole in the
1323
+ * script-budget gate, which tested it against the whole page. `MARKED_ROOT`
1324
+ * exists so a consumer cannot make that mistake by accident.
1325
+ */
1326
+ export { MARKED_ROOT, RENDERED_DIAGRAM, RENDERED_MARKER, RENDERED_MATH } from "./rendered-marker.js";
1327
+ /**
1328
+ * Write {@link RENDERED_MARKER} onto a replacement's root element.
1329
+ *
1330
+ * String surgery on the opening tag rather than a parse, because the input is
1331
+ * one renderer's own output rather than arbitrary HTML, and the alternative is a
1332
+ * second parse of every expression and diagram on the page. The root element is
1333
+ * the first tag in the string by construction: `renderMath` returns `<math…>`
1334
+ * and `renderDiagram` a `<figure…>`.
1335
+ *
1336
+ * Returns the markup unchanged when it does not begin with a tag, so a renderer
1337
+ * whose output shape changes degrades to *unmarked* rather than to corrupt. That
1338
+ * is the safe direction for both consumers: the residue scan treats an unmarked
1339
+ * region as ordinary text and scans it, and the budget gate grants no allowance.
1340
+ */
1341
+ function withRenderedMarker(markup, kind) {
1342
+ const opening = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(markup);
1343
+ if (opening === null)
1344
+ return markup;
1345
+ return `${markup.slice(0, opening[0].length)} ${RENDERED_MARKER}="${kind}"${markup.slice(opening[0].length)}`;
1346
+ }
1347
+ /**
1348
+ * Replace each placeholder with the markup it stands for.
1349
+ *
1350
+ * Positional and exhausting, which is what makes it safe to splice
1351
+ * sanitizer-exempt markup into sanitized HTML. Each token is looked for exactly
1352
+ * once and by its exact string; the replacement is chosen by the token's own
1353
+ * index in the list this render built, so no property of the *document* selects
1354
+ * what gets inserted. A count mismatch — the shape a note body writing the
1355
+ * literal token would produce — throws rather than resolving to something.
1356
+ *
1357
+ * `split`/`join` rather than `replace`: a `$` in the replacement is a
1358
+ * substitution pattern to `String.replace`, and rendered MathML and SVG both
1359
+ * contain `$` in base64 payloads and TeX text. `replaceAll` with a string
1360
+ * replacement has the same hazard.
1361
+ */
1362
+ function substituteRendered(html, replacements) {
1363
+ let result = html;
1364
+ for (const { token, markup } of replacements) {
1365
+ const parts = result.split(token);
1366
+ if (parts.length !== 2) {
1367
+ throw new Error(`rendering placeholder appeared ${parts.length - 1} times, expected exactly once. ` +
1368
+ 'A note body that writes the placeholder text itself causes this; the text is ' +
1369
+ `"${token}".`);
1370
+ }
1371
+ result = parts.join(markup);
1372
+ }
1373
+ // Every marker is now spent. A body that wrote one this render never minted —
1374
+ // a different index, or the diagram form on a page with only math — would
1375
+ // otherwise ship it as visible gibberish, while the *same* body on a page
1376
+ // whose indices happened to match failed the build. Checking the shape rather
1377
+ // than the exact strings makes the invariant total, so the two cases behave
1378
+ // alike and neither can reach a reader.
1379
+ const residue = new RegExp(`${DIAGRAM_TOKEN_PREFIX}|${MATH_TOKEN_PREFIX}`).exec(result);
1380
+ if (residue !== null) {
1381
+ throw new Error(`a rendering placeholder survived substitution: "${residue[0]}". A note body that ` +
1382
+ 'writes the placeholder text itself causes this.');
1383
+ }
1384
+ return result;
1385
+ }
1386
+ /** Nest a flat, document-order heading list by depth. */
1387
+ function buildToc(headings) {
1388
+ const roots = [];
1389
+ const open = [];
1390
+ for (const heading of headings) {
1391
+ const entry = { ...heading, children: [] };
1392
+ while (open.length > 0 && open[open.length - 1].depth >= entry.depth)
1393
+ open.pop();
1394
+ (open[open.length - 1]?.children ?? roots).push(entry);
1395
+ open.push(entry);
1396
+ }
1397
+ return roots;
1398
+ }
1399
+ /**
1400
+ * Render one artifact entry's Markdown to sanitized HTML and page metadata.
1401
+ *
1402
+ * @param markdown Body Markdown from a validated content entry.
1403
+ * @param options Injected rendering seams; see {@link RenderOptions}.
1404
+ */
1405
+ export async function renderMarkdown(markdown, options = {}) {
1406
+ const routeForSlug = options.routeForSlug ?? defaultRouteForSlug;
1407
+ // The document's own chrome, or the navigation language for a caller that
1408
+ // renders no particular document. See `RenderOptions.chrome`.
1409
+ const chrome = options.chrome ?? translate(NAV_LANGUAGE);
1410
+ const collected = {
1411
+ headings: [],
1412
+ rawIds: new Map(),
1413
+ hasCode: false,
1414
+ hasMath: false,
1415
+ hasMermaid: false,
1416
+ };
1417
+ const diagrams = [];
1418
+ const maths = [];
1419
+ const { html } = await markdownToHtml(markdown, {
1420
+ features: featuresFor(chrome),
1421
+ hastPlugins: [
1422
+ headingPlugin(collected, options.pageTitle, chrome.headingAnchorLabel),
1423
+ calloutPlugin(),
1424
+ mathPlugin(collected, maths),
1425
+ codePlugin(collected, diagrams, chrome.diagramCaption),
1426
+ taskListPlugin(),
1427
+ tablePlugin(),
1428
+ ],
1429
+ });
1430
+ const sanitized = sanitize(html, routeForSlug, new Set(collected.headings.map((heading) => heading.id)), collected.rawIds);
1431
+ // Rendered after sanitization, at markers the sanitizer preserved. Both
1432
+ // renderers are the trust boundary for their own output; see the note on
1433
+ // `DIAGRAM_TOKEN_PREFIX`. A render failure throws and stops the build, which
1434
+ // is the intended behaviour for a construct that cannot be shown safely.
1435
+ const replacements = [
1436
+ ...maths.map((request) => ({
1437
+ token: request.token,
1438
+ // Client mode ships the source for the browser to render; build-time mode
1439
+ // ships the MathML. Both are marked here, which is the whole point of
1440
+ // routing both through this one post-sanitization step.
1441
+ markup: withRenderedMarker(request.source === true
1442
+ ? `<code class="language-${MATH_LANGUAGE}">${escapeHtml(request.tex)}</code>`
1443
+ : renderMath(request.tex, request.isDisplay), RENDERED_MATH),
1444
+ })),
1445
+ // `diagramIdFor` namespaces element ids by the diagram's position, so two
1446
+ // diagrams on one page cannot both define `#arrowhead`.
1447
+ ...(await Promise.all(diagrams.map(async (request, index) => ({
1448
+ token: request.token,
1449
+ // Client mode ships the source fence for the browser to render;
1450
+ // build-time mode ships the SVG. Both are marked here, which is the
1451
+ // whole point of routing both through this one post-sanitization step.
1452
+ markup: withRenderedMarker(request.sourceOnly === true
1453
+ ? `<pre class="diagram-source" tabindex="0"><code class="language-${MERMAID_LANGUAGE}">${escapeHtml(request.source)}</code></pre>` +
1454
+ `<figcaption>${escapeHtml(request.caption ?? '')}</figcaption>`
1455
+ : // Imported only on this branch: `mermaid-render.ts` loads a DOM
1456
+ // shim that costs about a second, and `client` mode — the one that
1457
+ // ships — never reaches here, so it never pays for it.
1458
+ await (await import("./mermaid-render.js")).renderDiagram(request.source, `diagram-${index}`, diagramCaption(request.source, chrome.diagramCaption)), RENDERED_DIAGRAM),
1459
+ })))),
1460
+ ];
1461
+ return {
1462
+ html: substituteRendered(sanitized, replacements),
1463
+ headings: collected.headings,
1464
+ toc: collected.headings.length >= TOC_MIN_HEADINGS ? buildToc(collected.headings) : [],
1465
+ hasCode: collected.hasCode,
1466
+ hasMath: collected.hasMath,
1467
+ hasMermaid: collected.hasMermaid,
1468
+ };
1469
+ }