@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.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/astro.config.mjs +255 -0
- package/bin/anc.mjs +798 -0
- package/package.json +93 -0
- package/public/_headers +45 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/fonts/Temml.woff2 +0 -0
- package/scripts/build-snapshot.js +69 -0
- package/scripts/build-wasm.js +67 -0
- package/scripts/build-workspace.js +50 -0
- package/scripts/copy-snapshot.js +48 -0
- package/scripts/copy-wasm.js +57 -0
- package/scripts/emit-redirects.js +64 -0
- package/scripts/generate-corpus.js +421 -0
- package/scripts/init-repository.js +249 -0
- package/scripts/load-config.js +975 -0
- package/scripts/markdown-to-artifact.js +1286 -0
- package/scripts/preview-site.js +364 -0
- package/scripts/publish-set-review.js +157 -0
- package/scripts/resolve-links.js +640 -0
- package/scripts/run-pagefind.js +125 -0
- package/scripts/scan-residue.js +1016 -0
- package/scripts/scan-secrets.js +268 -0
- package/scripts/seed-gitignore.js +410 -0
- package/scripts/snapshot-rows.js +205 -0
- package/scripts/validate-content.js +95 -0
- package/scripts/vendor-provenance.js +307 -0
- package/scripts/verify-output-inventory.js +307 -0
- package/scripts/write-report.js +351 -0
- package/scripts/write-snapshot.js +136 -0
- package/src/components/CollectionExplorer.astro +149 -0
- package/src/components/FacetIndex.astro +45 -0
- package/src/components/LinkedNotes.astro +94 -0
- package/src/components/NoteGraph.astro +434 -0
- package/src/components/NoteList.astro +54 -0
- package/src/components/SiteMetadata.astro +143 -0
- package/src/components/TableOfContents.astro +69 -0
- package/src/components/TagBrowser.astro +70 -0
- package/src/layouts/Layout.astro +305 -0
- package/src/lib/artifact-source.js +72 -0
- package/src/lib/build-bindings.js +11 -0
- package/src/lib/collection-navigation.js +145 -0
- package/src/lib/content.js +92 -0
- package/src/lib/diagram-mode.js +18 -0
- package/src/lib/graph-layout.js +219 -0
- package/src/lib/graph-selection.js +84 -0
- package/src/lib/graph.js +42 -0
- package/src/lib/link-resolution.js +389 -0
- package/src/lib/markdown.js +1469 -0
- package/src/lib/math-mode.js +31 -0
- package/src/lib/math-output.js +131 -0
- package/src/lib/math.js +165 -0
- package/src/lib/mermaid-environment.js +475 -0
- package/src/lib/mermaid-render.js +769 -0
- package/src/lib/preview-model.js +96 -0
- package/src/lib/relations.js +242 -0
- package/src/lib/rendered-marker.js +55 -0
- package/src/lib/route-path.js +192 -0
- package/src/lib/routes.js +342 -0
- package/src/lib/schema.js +578 -0
- package/src/lib/site.js +578 -0
- package/src/lib/snapshot-contract.js +216 -0
- package/src/lib/snapshot-operations.js +147 -0
- package/src/lib/snapshot-queries.js +216 -0
- package/src/lib/snapshot-reader.js +210 -0
- package/src/lib/snapshot.js +233 -0
- package/src/lib/sqlite-warning.js +22 -0
- package/src/lib/sqlite.js +14 -0
- package/src/lib/tag-browser-model.js +18 -0
- package/src/lib/translations.js +533 -0
- package/src/lib/wasm-asset.js +47 -0
- package/src/lib/worker-protocol.js +93 -0
- package/src/pages/404.astro +35 -0
- package/src/pages/about.astro +100 -0
- package/src/pages/collections/[slug].astro +36 -0
- package/src/pages/collections/index.astro +33 -0
- package/src/pages/graph.astro +64 -0
- package/src/pages/index.astro +33 -0
- package/src/pages/notes/[slug].astro +509 -0
- package/src/pages/privacy.astro +121 -0
- package/src/pages/private.astro +59 -0
- package/src/pages/recent.astro +33 -0
- package/src/pages/robots.txt.js +15 -0
- package/src/pages/rss.xml.js +21 -0
- package/src/pages/sitemap.xml.js +20 -0
- package/src/pages/tags/[tag].astro +56 -0
- package/src/pages/tags/index.astro +37 -0
- package/src/scripts/code-copy.js +45 -0
- package/src/scripts/diagram-disabled.js +16 -0
- package/src/scripts/diagram.js +322 -0
- package/src/scripts/graph-client.js +222 -0
- package/src/scripts/graph-draw.js +166 -0
- package/src/scripts/link-preview.js +334 -0
- package/src/scripts/math-disabled.js +18 -0
- package/src/scripts/math.js +133 -0
- package/src/scripts/preferences.js +123 -0
- package/src/scripts/search-dialog.js +651 -0
- package/src/scripts/snapshot-client.js +157 -0
- package/src/scripts/snapshot-worker.js +341 -0
- package/src/scripts/tag-browser.js +153 -0
- package/src/scripts/theme-init.js +35 -0
- package/src/scripts/toc-active.js +38 -0
- package/src/styles/code.css +152 -0
- package/src/styles/diagram.css +149 -0
- package/src/styles/global.css +1739 -0
- package/src/styles/graph.css +317 -0
- package/src/styles/math.css +376 -0
- package/src/styles/tokens.css +171 -0
- 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
|
+
'&': '&',
|
|
229
|
+
'<': '<',
|
|
230
|
+
'>': '>',
|
|
231
|
+
'"': '"',
|
|
232
|
+
"'": ''',
|
|
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 (`_`,
|
|
297
|
+
* `_`). `<a id="foo_bar">` is the id `foo_bar`, and a decoder
|
|
298
|
+
* that handles only numeric references reads it as the literal
|
|
299
|
+
* `foo_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
|
+
// (`introduction` 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
|
+
}
|