@jarenjs/md 0.34.0

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 (49) hide show
  1. package/README.md +520 -0
  2. package/dist/types/ast.d.ts +181 -0
  3. package/dist/types/bake.d.ts +61 -0
  4. package/dist/types/compiler.d.ts +141 -0
  5. package/dist/types/component/index.d.ts +101 -0
  6. package/dist/types/directives.d.ts +126 -0
  7. package/dist/types/entities.d.ts +40 -0
  8. package/dist/types/footnotes.d.ts +83 -0
  9. package/dist/types/frontmatter.d.ts +67 -0
  10. package/dist/types/html.d.ts +72 -0
  11. package/dist/types/index.d.ts +30 -0
  12. package/dist/types/loader.d.ts +84 -0
  13. package/dist/types/mdx.d.ts +45 -0
  14. package/dist/types/parser.d.ts +116 -0
  15. package/dist/types/plugins/highlight.d.ts +64 -0
  16. package/dist/types/plugins/index.d.ts +64 -0
  17. package/dist/types/plugins/mermaid.d.ts +12 -0
  18. package/dist/types/scanner.d.ts +240 -0
  19. package/dist/types/to-html.d.ts +104 -0
  20. package/dist/types/to-md.d.ts +23 -0
  21. package/dist/types/to-vnode.d.ts +161 -0
  22. package/dist/types/utils.d.ts +63 -0
  23. package/docs/LOADER.md +92 -0
  24. package/docs/MD-FORMAT.md +502 -0
  25. package/docs/PLUGINS.md +277 -0
  26. package/package.json +80 -0
  27. package/schemas/jaren-md-ast.schema.json +296 -0
  28. package/src/ast.js +346 -0
  29. package/src/bake.js +104 -0
  30. package/src/compiler.js +167 -0
  31. package/src/component/index.js +191 -0
  32. package/src/directives.js +371 -0
  33. package/src/entities.js +107 -0
  34. package/src/footnotes.js +180 -0
  35. package/src/frontmatter.js +947 -0
  36. package/src/html.js +281 -0
  37. package/src/index.js +76 -0
  38. package/src/loader.js +0 -0
  39. package/src/mdx.js +219 -0
  40. package/src/parser.js +1685 -0
  41. package/src/plugins/highlight.js +325 -0
  42. package/src/plugins/index.js +75 -0
  43. package/src/plugins/mermaid.js +14 -0
  44. package/src/scanner.js +832 -0
  45. package/src/to-html.js +425 -0
  46. package/src/to-md.js +396 -0
  47. package/src/to-vnode.js +766 -0
  48. package/src/utils.js +107 -0
  49. package/styles/md.css +238 -0
@@ -0,0 +1,766 @@
1
+ //@ts-check
2
+ /**
3
+ * @file AST → @jarenjs/view vnodes, and the hydrating renderer.
4
+ *
5
+ * The emitter is a prebuilt dispatch table keyed on node type; plugin
6
+ * `render` entries shadow the core entries (that is how the highlight
7
+ * plugin takes over `code` nodes). Two identity guarantees feed the
8
+ * view patcher's O(1) fast paths (VIEW-FORMAT.md §5.1):
9
+ *
10
+ * - per-node memo: the same AST node reference emits the same vnode
11
+ * reference, so unchanged subtrees of a JSLT-transformed document
12
+ * patch in O(1);
13
+ * - content-hash keys: block vnodes are keyed by a hash of their
14
+ * content, so moved blocks reorder instead of rebuilding.
15
+ *
16
+ * Raw HTML nodes are dropped by default; `options.html: 'text'` shows
17
+ * them literally, and `'vnode'` PARSES them through an allow-list
18
+ * (`parseHtmlFragment`, or an injected `parseHtml`). The vnode format
19
+ * has no unescaped output in any of the three, which is what makes
20
+ * dropping the safe default for untrusted Markdown. Link and image URLs are
21
+ * filtered on the same principle: a destination whose scheme can execute
22
+ * (`javascript:`, `vbscript:`) or stand in for a document
23
+ * (`data:text/html`, `file:`) loses its attribute rather than reaching
24
+ * the page. The AST keeps the URL verbatim, so `toMarkdown` still
25
+ * round-trips what the author wrote — only the vnode is filtered.
26
+ */
27
+
28
+ import { h, createDomRenderer } from '@jarenjs/view';
29
+ import { sanitizeUrl as defaultSanitizeUrl, encodeUrlAttribute } from '@jarenjs/view/helpers';
30
+ import {
31
+ hashContent, fnv1a, FNV1A_OFFSET_BASIS, headingId, permalinkLabel,
32
+ } from './utils.js';
33
+ import { parseHtmlFragment, parseHtmlTag } from './html.js';
34
+ import { walkAst, textOf } from './ast.js';
35
+ import { buildPluginTables } from './parser.js';
36
+ import {
37
+ collectFootnotes, footnoteId, footnoteRefId, backrefLabel,
38
+ FOOTNOTE_PREFIX, BACKREF_MARK,
39
+ } from './footnotes.js';
40
+
41
+ /**
42
+ * @typedef {import('./ast.js').MdNode} MdNode
43
+ * @typedef {import('./ast.js').MdDocument} MdDocument
44
+ */
45
+ /**
46
+ * @typedef {object} MdVnodeOptions
47
+ * @property {any[]} [plugins] plugin set (must match the parse set for claimed nodes)
48
+ * @property {'skip'|'text'|'vnode'} [html] raw HTML handling (default
49
+ * 'skip'): drop it, show it as literal text, or parse it to vnodes
50
+ * @property {(html: string) => any} [parseHtml] the parser `html: 'vnode'`
51
+ * uses (default `parseHtmlFragment` from `@jarenjs/md/html`) — the
52
+ * injection point for a host's own sanitizer. It returns a LIST of
53
+ * vnodes (empty or null when nothing survived); an array is always
54
+ * read as a list, because a vnode is an array too and the two would
55
+ * otherwise be indistinguishable.
56
+ * @property {(url: string) => (string|null)} [sanitizeUrl] link/image URL
57
+ * filter, replacing the default deny-list; return the URL to emit, or
58
+ * `null` to drop the attribute. Supply one only to widen the policy for
59
+ * trusted content (a custom scheme, say) — it is the whole guard.
60
+ * @property {boolean} [headingIds] give every heading a GitHub-compatible
61
+ * `id` so `[see below](#the-section)` lands (default `false`). The
62
+ * default is OFF ON PURPOSE and must stay that way: CommonMark
63
+ * specifies `<h1>Foo</h1>`, so an id emitted by default would fail
64
+ * every heading example in the conformance corpus and make the
65
+ * package's published score a lie. A host that wants anchors asks for
66
+ * them; the spec path stays honest.
67
+ * @property {string} [slugPrefix] prepended to every heading id and
68
+ * anchor href (default `''`). A host rendering markdown it did not
69
+ * author into a page it owns sets this — GitHub's own answer is
70
+ * `user-content-` — so an author cannot mint an id that collides with
71
+ * the host's own DOM.
72
+ * @property {boolean} [headingAnchors] append a `#` link to each heading
73
+ * so a reader can copy a link to the section (default `false`).
74
+ * Requires `headingIds`; without ids there is nothing to link to.
75
+ * @property {string} [footnotesLabel] the accessible name of the
76
+ * appended footnotes section (default `'Footnotes'`) — the one string
77
+ * this emitter writes that a reader can hear.
78
+ * @property {boolean} [keyed] give each top-level block a content-hash
79
+ * `key` (default `true`).
80
+ *
81
+ * Keys are what let the view patcher REORDER blocks instead of
82
+ * rebuilding them, so any caller whose output will be patched needs
83
+ * them — and computing one means hashing the block's whole subtree,
84
+ * which is around a quarter of this emitter's cost. A caller that
85
+ * renders once and throws the tree away (SSR, a string, a snapshot)
86
+ * pays that for nothing and should pass `false`.
87
+ *
88
+ * It is deliberately NOT inferred. A renderer cannot know whether its
89
+ * output will be patched, and guessing wrong silently turns O(1)
90
+ * reconciliation into a rebuild — a correctness-shaped failure with no
91
+ * error message. The default is the safe answer; opting out is a
92
+ * statement about the caller, which is why every caller in this
93
+ * repository that passes `false` says why.
94
+ */
95
+
96
+ /**
97
+ * One raw-HTML node, under whichever policy is in force.
98
+ *
99
+ * `'vnode'` parses; what the parser returns may be several nodes, so a
100
+ * BLOCK wraps them in a `<div>` (a block-level html node stands where a
101
+ * block does) while an INLINE one returns the array for the caller to
102
+ * splice. Nothing surviving the parse renders nothing at all, which is
103
+ * the same outcome as `'skip'` — an allow-list that rejected everything
104
+ * must not leave an empty wrapper behind.
105
+ * @param {any} node @param {any} rctx
106
+ * @param {string|null} blockClass class for the block form, null = inline
107
+ * @returns {any}
108
+ */
109
+ function htmlNodeVnode(node, rctx, blockClass) {
110
+ if (rctx.html === 'skip') return null;
111
+ if (rctx.html === 'text') {
112
+ return blockClass === null ? node.value : ['pre', { class: blockClass }, node.value];
113
+ }
114
+ const parsed = rctx.parseHtml(node.value);
115
+ const children = parsed === null || parsed === undefined
116
+ ? []
117
+ : Array.isArray(parsed) ? parsed : [parsed];
118
+ if (children.length === 0) return null;
119
+ if (blockClass === null) return children;
120
+ return children.length === 1 && Array.isArray(children[0])
121
+ ? children[0]
122
+ : ['div', { class: blockClass }, ...children];
123
+ }
124
+
125
+ /**
126
+ * The render context threaded through one emission.
127
+ * @typedef {{ tables: any, html: 'skip'|'text'|'vnode', options: MdVnodeOptions,
128
+ * parseHtml: (html: string) => any,
129
+ * sanitizeUrl: (url: string) => (string|null),
130
+ * headingIds: boolean, slugPrefix: string, headingAnchors: boolean,
131
+ * slugs: Map<string, number>,
132
+ * footnotes: import('./footnotes.js').Footnotes|null,
133
+ * footnotePrefix: string, footnotesLabel: string,
134
+ * hash: (str: string) => string, counts: Map<string, number> }} RenderCtx
135
+ */
136
+
137
+ // ------------------------------------------------------------------
138
+ // Inline emission
139
+ // ------------------------------------------------------------------
140
+
141
+ /**
142
+ * Emit the children of an inline container.
143
+ * @param {MdNode[]} nodes
144
+ * @param {RenderCtx} rctx
145
+ * @returns {any[]}
146
+ */
147
+ function inlineChildren(nodes, rctx) {
148
+ const out = [];
149
+ for (let i = 0; i < nodes.length; i++) {
150
+ out.push(inlineVnode(nodes[i], rctx));
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * Append inline children directly onto a vnode under construction
157
+ * (saves the intermediate array + spread on the hot path).
158
+ * @param {any[]} vnode
159
+ * @param {MdNode[]} nodes
160
+ * @param {RenderCtx} rctx
161
+ * @returns {any[]}
162
+ */
163
+ function intoVnode(vnode, nodes, rctx) {
164
+ if (rctx.html === 'vnode' && hasInlineHtml(nodes)) {
165
+ vnode.push(...pairInlineHtml(nodes, rctx));
166
+ return vnode;
167
+ }
168
+ for (let i = 0; i < nodes.length; i++) {
169
+ vnode.push(inlineVnode(nodes[i], rctx));
170
+ }
171
+ return vnode;
172
+ }
173
+
174
+ /** Does this inline run contain a raw-HTML node at all? */
175
+ function hasInlineHtml(nodes) {
176
+ for (let i = 0; i < nodes.length; i++) {
177
+ if (nodes[i].type === 'html') return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ /**
183
+ * Assemble an inline run whose raw HTML comes one TAG at a time.
184
+ *
185
+ * CommonMark's inline phase emits `<b>bold</b>` as three siblings — an
186
+ * html node, a text node, an html node — because at that level a tag is
187
+ * not an element. Parsing each html node on its own would produce an
188
+ * empty `<b></b>` followed by loose text, so the run is re-paired here:
189
+ * an opening tag opens a frame, the matching closing tag closes it, and
190
+ * everything between becomes its children. A tag left open at the end of
191
+ * the run closes there, as it does inside a fragment.
192
+ *
193
+ * Only the default parser can be asked to classify a lone tag; an
194
+ * injected `parseHtml` keeps the simple path (parse each node on its
195
+ * own), because a host's parser answers a different question.
196
+ * @param {MdNode[]} nodes @param {RenderCtx} rctx
197
+ * @returns {any[]}
198
+ */
199
+ function pairInlineHtml(nodes, rctx) {
200
+ const stack = [{ tag: null, props: null, children: /** @type {any[]} */ ([]) }];
201
+ const top = () => stack[stack.length - 1].children;
202
+ for (let i = 0; i < nodes.length; i++) {
203
+ const node = nodes[i];
204
+ if (node.type !== 'html') {
205
+ top().push(inlineVnode(node, rctx));
206
+ continue;
207
+ }
208
+ const tag = rctx.parseHtml === parseHtmlFragment ? parseHtmlTag(node.value) : null;
209
+ if (tag === null) {
210
+ // not one plain tag (a comment, a whole element, an injected
211
+ // parser): whatever the parser makes of it stands on its own
212
+ const parsed = rctx.parseHtml(node.value);
213
+ if (Array.isArray(parsed)) top().push(...parsed);
214
+ else if (parsed !== null && parsed !== undefined) top().push(parsed);
215
+ continue;
216
+ }
217
+ if (tag.closing) {
218
+ // close the nearest frame this end tag matches; an unmatched one
219
+ // is dropped, like a stray `</div>` in a fragment
220
+ for (let depth = stack.length - 1; depth > 0; depth--) {
221
+ if (stack[depth].tag !== tag.name) continue;
222
+ while (stack.length > depth) closeInlineFrame(stack);
223
+ break;
224
+ }
225
+ continue;
226
+ }
227
+ if (tag.complete) {
228
+ if (tag.props !== null) top().push([tag.name, tag.props]);
229
+ continue;
230
+ }
231
+ stack.push({ tag: tag.name, props: tag.props, drop: tag.drop, children: [] });
232
+ }
233
+ while (stack.length > 1) closeInlineFrame(stack);
234
+ return stack[0].children;
235
+ }
236
+
237
+ /**
238
+ * Pop one inline frame into its parent: a known element keeps its props
239
+ * and children, an unknown one keeps only its children, and one whose
240
+ * content is not prose (`<script>`) keeps neither.
241
+ */
242
+ function closeInlineFrame(stack) {
243
+ const frame = stack.pop();
244
+ const parent = stack[stack.length - 1].children;
245
+ if (frame.drop === true) return;
246
+ if (frame.props !== null) parent.push([frame.tag, frame.props, ...frame.children]);
247
+ else parent.push(...frame.children);
248
+ }
249
+
250
+ /** @type {Record<string, (node: MdNode, rctx: RenderCtx) => any>} */
251
+ const INLINE_RENDERERS = {
252
+ text: (node) => node.value,
253
+ emphasis: (node, rctx) => intoVnode(['em', {}], node.children, rctx),
254
+ strong: (node, rctx) => intoVnode(['strong', {}], node.children, rctx),
255
+ strikethrough: (node, rctx) => intoVnode(['del', {}], node.children, rctx),
256
+ inlineCode: (node) => ['code', {}, node.value],
257
+ link: (node, rctx) => {
258
+ // A rejected destination drops the attribute and keeps the element:
259
+ // the link text stays readable, it just is not clickable. What
260
+ // survives is percent-encoded: the AST holds the destination the
261
+ // author wrote, an attribute needs a URL a browser resolves the same
262
+ // way (CommonMark's rendering rule).
263
+ const href = rctx.sanitizeUrl(node.url);
264
+ const props = href === null ? {} : { href: encodeUrlAttribute(href) };
265
+ if (node.title != null) props.title = node.title;
266
+ return intoVnode(['a', props], node.children, rctx);
267
+ },
268
+ image: (node, rctx) => {
269
+ const src = rctx.sanitizeUrl(node.url);
270
+ const props = src === null
271
+ ? { alt: node.alt }
272
+ : { src: encodeUrlAttribute(src), alt: node.alt };
273
+ if (node.title != null) props.title = node.title;
274
+ return ['img', props];
275
+ },
276
+ break: () => ['br', {}],
277
+ softBreak: () => '\n',
278
+ html: (node, rctx) => htmlNodeVnode(node, rctx, null),
279
+
280
+ footnoteReference: (node, rctx) => {
281
+ const cite = rctx.footnotes?.refs.get(node);
282
+ // see the string emitter: a citation of nothing renders as the text
283
+ // it was written as, never as a link to a missing anchor
284
+ if (cite === undefined) return '[^' + (node.label ?? node.identifier) + ']';
285
+ const prefix = rctx.footnotePrefix;
286
+ return ['sup', {}, ['a', {
287
+ href: '#' + footnoteId(prefix, cite.number),
288
+ id: footnoteRefId(prefix, cite.number, cite.occurrence),
289
+ }, String(cite.number)]];
290
+ },
291
+ };
292
+
293
+ /**
294
+ * Emit one inline node (plugin renders shadow the core table).
295
+ * @param {MdNode} node
296
+ * @param {RenderCtx} rctx
297
+ * @returns {any}
298
+ */
299
+ function inlineVnode(node, rctx) {
300
+ const plugin = rctx.tables.renders.get(node.type);
301
+ if (plugin !== undefined) return plugin.render(node, h, rctx);
302
+ const renderer = INLINE_RENDERERS[node.type];
303
+ if (renderer !== undefined) return renderer(node, rctx);
304
+ return fallbackVnode(node, rctx, true);
305
+ }
306
+
307
+ // ------------------------------------------------------------------
308
+ // Block emission
309
+ // ------------------------------------------------------------------
310
+
311
+ /** @type {Record<string, (node: MdNode, rctx: RenderCtx) => any>} */
312
+ const BLOCK_RENDERERS = {
313
+ paragraph: (node, rctx) => intoVnode(['p', {}], node.children, rctx),
314
+
315
+ heading: (node, rctx) => {
316
+ if (rctx.headingIds !== true) return intoVnode(['h' + node.depth, {}], node.children, rctx);
317
+ const id = headingId(textOf(node), rctx.slugs, rctx.slugPrefix);
318
+ const vnode = intoVnode(['h' + node.depth, { id }], node.children, rctx);
319
+ if (rctx.headingAnchors === true) vnode.push(headingAnchor(node, id));
320
+ return vnode;
321
+ },
322
+
323
+ thematicBreak: () => ['hr', {}],
324
+
325
+ blockquote: (node, rctx) => ['blockquote', {}, ...blockChildren(node.children, rctx, false)],
326
+
327
+ list: (node, rctx) => {
328
+ const items = [];
329
+ for (let i = 0; i < node.children.length; i++) {
330
+ items.push(listItemVnode(node.children[i], rctx, node.tight));
331
+ }
332
+ return node.ordered
333
+ ? (node.start !== null && node.start !== 1
334
+ ? ['ol', { start: node.start }, items]
335
+ : ['ol', {}, items])
336
+ : ['ul', {}, items];
337
+ },
338
+
339
+ code: (node) => {
340
+ const props = node.lang === null ? {} : { class: 'language-' + node.lang };
341
+ return ['pre', {}, ['code', props, node.value]];
342
+ },
343
+
344
+ html: (node, rctx) => htmlNodeVnode(node, rctx, 'md-html'),
345
+
346
+ table: (node, rctx) => {
347
+ const rows = node.children;
348
+ if (rows.length === 0) return ['table', {}];
349
+ const align = node.align;
350
+ const head = ['tr', {}, ...cellVnodes(rows[0].children, align, 'th', rctx)];
351
+ const body = [];
352
+ for (let r = 1; r < rows.length; r++) {
353
+ body.push(['tr', {}, ...cellVnodes(rows[r].children, align, 'td', rctx)]);
354
+ }
355
+ return ['table', {},
356
+ ['thead', {}, head],
357
+ body.length === 0 ? null : ['tbody', {}, body]];
358
+ },
359
+
360
+ // Collected, not rendered in place (MD-FORMAT.md §4.6).
361
+ footnoteDefinition: () => null,
362
+
363
+ custom: (node, rctx) => fallbackVnode(node, rctx, false),
364
+ };
365
+
366
+ /**
367
+ * The footnotes section appended after the last block: one `<li>` per
368
+ * cited definition, in first-citation order, each ending in a
369
+ * back-reference per citation. Keyed, because it sits among the keyed
370
+ * block children of the article.
371
+ * @param {RenderCtx} rctx
372
+ * @returns {any}
373
+ */
374
+ function footnotesVnode(rctx) {
375
+ const notes = rctx.footnotes;
376
+ if (notes === null || notes.defs.length === 0) return null;
377
+ const prefix = rctx.footnotePrefix;
378
+ const items = [];
379
+ for (let i = 0; i < notes.defs.length; i++) {
380
+ const def = notes.defs[i];
381
+ const number = /** @type {number} */ (notes.numbers.get(def.identifier));
382
+ const back = [];
383
+ const times = notes.counts.get(def.identifier) ?? 1;
384
+ for (let k = 1; k <= times; k++) {
385
+ back.push(' ');
386
+ back.push(['a', {
387
+ class: 'footnote-backref',
388
+ href: '#' + footnoteRefId(prefix, number, k),
389
+ 'aria-label': backrefLabel(number, k),
390
+ }, BACKREF_MARK, k > 1 ? ['sup', {}, String(k)] : null]);
391
+ }
392
+ /** @type {any[]} */
393
+ const content = [];
394
+ const blocks = def.children;
395
+ const last = blocks.length - 1;
396
+ for (let b = 0; b < blocks.length; b++) {
397
+ content.push(b === last && blocks[b].type === 'paragraph'
398
+ ? ['p', {}, ...inlineChildren(blocks[b].children, rctx), ...back]
399
+ : blockVnode(blocks[b], rctx, false));
400
+ }
401
+ if (blocks.length === 0 || blocks[last].type !== 'paragraph') content.push(['p', {}, ...back]);
402
+ items.push(['li', { id: footnoteId(prefix, number), key: 'fn-' + number }, ...content]);
403
+ }
404
+ return ['section', {
405
+ class: 'footnotes', 'aria-label': rctx.footnotesLabel, key: 'md-footnotes',
406
+ }, ['ol', {}, items]];
407
+ }
408
+
409
+ /**
410
+ * The copy-a-link affordance appended to a heading: a real link, so it is
411
+ * reachable by keyboard, with an accessible name that says which section
412
+ * it points at.
413
+ * @param {MdNode} node
414
+ * @param {string} id
415
+ * @returns {any[]}
416
+ */
417
+ function headingAnchor(node, id) {
418
+ return ['a', {
419
+ class: 'md-anchor',
420
+ href: '#' + id,
421
+ 'aria-label': permalinkLabel(textOf(node)),
422
+ }, '#'];
423
+ }
424
+
425
+ /**
426
+ * @param {MdNode[]} cells
427
+ * @param {(string|null)[]} align
428
+ * @param {string} tag
429
+ * @param {RenderCtx} rctx
430
+ * @returns {any[]}
431
+ */
432
+ function cellVnodes(cells, align, tag, rctx) {
433
+ const out = [];
434
+ for (let c = 0; c < cells.length; c++) {
435
+ const a = align[c] ?? null;
436
+ const props = a === null ? {} : { style: 'text-align:' + a };
437
+ out.push(intoVnode([tag, props], cells[c].children, rctx));
438
+ }
439
+ return out;
440
+ }
441
+
442
+ /**
443
+ * @param {MdNode} item
444
+ * @param {RenderCtx} rctx
445
+ * @param {boolean} tight
446
+ * @returns {any}
447
+ */
448
+ function listItemVnode(item, rctx, tight) {
449
+ /** @type {any[]} */
450
+ const content = [];
451
+ if (item.checked !== null && item.checked !== undefined) {
452
+ content.push(['input', { type: 'checkbox', checked: item.checked, disabled: true }]);
453
+ content.push(' ');
454
+ }
455
+ const children = item.children;
456
+ for (let i = 0; i < children.length; i++) {
457
+ const child = children[i];
458
+ // A tight item's blocks are separated by a newline: with the
459
+ // paragraphs unwrapped there is no element boundary left to do it,
460
+ // and `<h2>Bar</h2>baz` would run a heading into the text below it.
461
+ if (i > 0) content.push('\n');
462
+ // Tight lists unwrap their paragraphs (standard HTML rendering).
463
+ if (tight && child.type === 'paragraph') {
464
+ content.push(...inlineChildren(child.children, rctx));
465
+ }
466
+ else {
467
+ content.push(blockVnode(child, rctx, false));
468
+ }
469
+ }
470
+ return ['li', {}, ...content];
471
+ }
472
+
473
+ /**
474
+ * Unknown node types degrade honestly: literal nodes render their
475
+ * value as preformatted text, containers render their children.
476
+ * @param {MdNode} node
477
+ * @param {RenderCtx} rctx
478
+ * @param {boolean} inline
479
+ * @returns {any}
480
+ */
481
+ function fallbackVnode(node, rctx, inline) {
482
+ if (typeof node.value === 'string') {
483
+ return inline
484
+ ? ['code', { class: 'md-' + node.type }, node.value]
485
+ : ['pre', { class: 'md-' + node.type }, node.value];
486
+ }
487
+ if (Array.isArray(node.children)) {
488
+ return inline
489
+ ? ['span', { class: 'md-' + node.type }, ...inlineChildren(node.children, rctx)]
490
+ : ['div', { class: 'md-' + node.type }, ...blockChildren(node.children, rctx, false)];
491
+ }
492
+ return null;
493
+ }
494
+
495
+ /**
496
+ * @param {MdNode[]} blocks
497
+ * @param {RenderCtx} rctx
498
+ * @param {boolean} keyed content-hash keys on each block (top level only)
499
+ * @returns {any[]}
500
+ */
501
+ function blockChildren(blocks, rctx, keyed) {
502
+ const out = [];
503
+ for (let i = 0; i < blocks.length; i++) {
504
+ out.push(blockVnode(blocks[i], rctx, keyed));
505
+ }
506
+ return out;
507
+ }
508
+
509
+ /**
510
+ * Emit one block node through the per-node memo.
511
+ * @param {MdNode} node
512
+ * @param {RenderCtx} rctx
513
+ * @param {boolean} keyed
514
+ * @returns {any}
515
+ */
516
+ function blockVnode(node, rctx, keyed) {
517
+ /** @type {WeakMap<MdNode, any>} */
518
+ const memo = rctx.tables.vnodeMemo;
519
+ const cached = memo.get(node);
520
+ // The memo outlives one emission (a CompiledMd carries it), so every
521
+ // option that changes the output has to be part of the cache identity.
522
+ if (cached !== undefined && cached.keyed === keyed && cached.html === rctx.html
523
+ && cached.sanitizeUrl === rctx.sanitizeUrl && cached.headingIds === rctx.headingIds
524
+ && cached.slugPrefix === rctx.slugPrefix && cached.headingAnchors === rctx.headingAnchors
525
+ // Footnote numbering is a property of the WHOLE document, so a block
526
+ // carrying a citation cannot be reused across two documents that
527
+ // number it differently. The collection is memoized per AST array,
528
+ // which is what keeps re-rendering ONE document on the fast path;
529
+ // documents without footnotes compare `null === null` and are
530
+ // untouched by this.
531
+ && cached.footnotes === rctx.footnotes) {
532
+ return cached.vnode;
533
+ }
534
+ const plugin = rctx.tables.renders.get(node.type);
535
+ let vnode = plugin !== undefined
536
+ ? plugin.render(node, h, rctx)
537
+ : (BLOCK_RENDERERS[node.type] ?? ((n, ctx) => fallbackVnode(n, ctx, false)))(node, rctx);
538
+ if (keyed && Array.isArray(vnode) && typeof vnode[0] === 'string') {
539
+ vnode = withKey(vnode, blockKey(node, rctx));
540
+ }
541
+ memo.set(node, {
542
+ vnode, keyed, html: rctx.html, sanitizeUrl: rctx.sanitizeUrl,
543
+ headingIds: rctx.headingIds, slugPrefix: rctx.slugPrefix,
544
+ headingAnchors: rctx.headingAnchors, footnotes: rctx.footnotes,
545
+ });
546
+ return vnode;
547
+ }
548
+
549
+ /**
550
+ * Content-hash key for a block, with an occurrence counter so equal
551
+ * blocks stay distinct among siblings. The hash walks the node
552
+ * structurally (FNV-1a over keys and values) — no JSON string is ever
553
+ * built, so keying is O(content) with a small constant.
554
+ * @param {MdNode} node
555
+ * @param {RenderCtx} rctx
556
+ * @returns {string}
557
+ */
558
+ function blockKey(node, rctx) {
559
+ const base = (hashValue(FNV1A_OFFSET_BASIS, node) >>> 0).toString(36);
560
+ const seen = rctx.counts.get(base) ?? 0;
561
+ rctx.counts.set(base, seen + 1);
562
+ return seen === 0 ? base : base + ':' + seen;
563
+ }
564
+
565
+ /**
566
+ * FNV-1a over a JSON value's structure (deterministic member order —
567
+ * the AST constructors build every node of a type with the same key
568
+ * order). The walk is md-specific — type tags are folded in with a
569
+ * `* 31` step so `{a: 1}` and `['a', 1]` differ — but every string is
570
+ * mixed through the suite's single `fnv1a` step, seeded with the hash
571
+ * so far.
572
+ * @param {number} h running unsigned 32-bit hash
573
+ * @param {any} value
574
+ * @returns {number}
575
+ */
576
+ function hashValue(h, value) {
577
+ switch (typeof value) {
578
+ case 'string':
579
+ return fnv1a(value, (h * 31 + 1) >>> 0);
580
+ case 'number':
581
+ return fnv1a(String(value), (h * 31 + 2) >>> 0);
582
+ case 'boolean':
583
+ return ((h * 31 + (value ? 3 : 4)) * 0x01000193) >>> 0;
584
+ default:
585
+ break;
586
+ }
587
+ if (value === null || value === undefined) {
588
+ return ((h * 31 + 5) * 0x01000193) >>> 0;
589
+ }
590
+ if (Array.isArray(value)) {
591
+ h = (h * 31 + 6) >>> 0;
592
+ for (let i = 0; i < value.length; i++) h = hashValue(h, value[i]);
593
+ return h;
594
+ }
595
+ h = (h * 31 + 7) >>> 0;
596
+ // `for…in` rather than `Object.keys()`: the key array is allocated and
597
+ // thrown away once per NODE, and this walk visits every node of every
598
+ // block. AST nodes are object literals with no enumerable inherited
599
+ // members and no integer-like keys, so the two enumerate the same
600
+ // names in the same order — the hash is byte-identical, which the
601
+ // corpus test asserts.
602
+ for (const key in value) {
603
+ h = fnv1a(key, h);
604
+ h = hashValue(h, value[key]);
605
+ }
606
+ return h;
607
+ }
608
+
609
+ /**
610
+ * Return a copy of an element vnode with `key` set (never mutates the
611
+ * possibly-shared original).
612
+ * @param {any[]} vnode
613
+ * @param {string} key
614
+ * @returns {any[]}
615
+ */
616
+ function withKey(vnode, key) {
617
+ const out = vnode.slice();
618
+ const props = out.length > 1 && out[1] !== null && typeof out[1] === 'object' && !Array.isArray(out[1])
619
+ ? out[1] : null;
620
+ if (props === null) out.splice(1, 0, { key });
621
+ else if (props.key === undefined) out[1] = { ...props, key };
622
+ return out;
623
+ }
624
+
625
+ /**
626
+ * Emit a whole document (or AST array) as one `article.md` vnode with
627
+ * content-hash-keyed block children.
628
+ *
629
+ * @example
630
+ * mdToVnode(parseMarkdown('# Hi'))
631
+ * // ['article', { class: 'md' }, [['h1', { key: '…' }, 'Hi']]]
632
+ *
633
+ * @param {any} docOrCompiled MdDocument, CompiledMd or MdNode[]
634
+ * @param {MdVnodeOptions} [options]
635
+ * @returns {any}
636
+ */
637
+ export function mdToVnode(docOrCompiled, options = {}) {
638
+ const ast = Array.isArray(docOrCompiled)
639
+ ? docOrCompiled
640
+ : Array.isArray(docOrCompiled.ast) ? docOrCompiled.ast : [docOrCompiled];
641
+ const tables = docOrCompiled.tables ?? buildPluginTables(options.plugins);
642
+ /** @type {RenderCtx} */
643
+ const rctx = {
644
+ tables,
645
+ html: options.html === 'text' || options.html === 'vnode' ? options.html : 'skip',
646
+ parseHtml: typeof options.parseHtml === 'function' ? options.parseHtml : parseHtmlFragment,
647
+ sanitizeUrl: typeof options.sanitizeUrl === 'function'
648
+ ? options.sanitizeUrl
649
+ : defaultSanitizeUrl,
650
+ headingIds: options.headingIds === true,
651
+ slugPrefix: typeof options.slugPrefix === 'string' ? options.slugPrefix : '',
652
+ headingAnchors: options.headingIds === true && options.headingAnchors === true,
653
+ slugs: new Map(),
654
+ options,
655
+ footnotes: collectFootnotes(ast),
656
+ footnotePrefix: typeof options.slugPrefix === 'string' ? options.slugPrefix : FOOTNOTE_PREFIX,
657
+ footnotesLabel: typeof options.footnotesLabel === 'string' ? options.footnotesLabel : 'Footnotes',
658
+ hash: hashContent,
659
+ counts: new Map(),
660
+ };
661
+ const children = blockChildren(ast, rctx, options.keyed !== false);
662
+ const notes = footnotesVnode(rctx);
663
+ if (notes !== null) children.push(notes);
664
+ return ['article', { class: 'md' }, children];
665
+ }
666
+
667
+ // ------------------------------------------------------------------
668
+ // The hydrating renderer
669
+ // ------------------------------------------------------------------
670
+
671
+ /**
672
+ * Create a renderer over `@jarenjs/view`'s DOM patcher that also runs
673
+ * plugin `hydrate` hooks after mount (PLUGINS.md §5). Returns a
674
+ * `render(docOrCompiled)` function.
675
+ *
676
+ * @param {{
677
+ * container: any,
678
+ * plugins?: any[],
679
+ * html?: 'skip'|'text',
680
+ * headingIds?: boolean,
681
+ * slugPrefix?: string,
682
+ * headingAnchors?: boolean,
683
+ * document?: any,
684
+ * onEvent?: (binding: any, event: any) => void,
685
+ * onHydrateError?: (err: any) => void,
686
+ * }} options
687
+ * @returns {(docOrCompiled: any) => void}
688
+ */
689
+ export function createMdRenderer(options) {
690
+ // Loaded lazily so SSR-only consumers never touch the DOM module.
691
+ /** @type {any} */
692
+ let domRender = null;
693
+ const tables = buildPluginTables(options.plugins);
694
+ const onHydrateError = options.onHydrateError
695
+ // eslint-disable-next-line no-console -- the documented default sink
696
+ ?? ((err) => console.error('md hydrate:', err));
697
+ /** @type {WeakMap<any, string>} */
698
+ const hydrated = new WeakMap();
699
+
700
+ return function render(docOrCompiled) {
701
+ if (domRender === null) {
702
+ domRender = createDomRenderer(options.container, {
703
+ document: options.document,
704
+ onEvent: options.onEvent,
705
+ });
706
+ }
707
+ const vnode = typeof docOrCompiled.toVnode === 'function'
708
+ ? docOrCompiled.toVnode()
709
+ : mdToVnode(docOrCompiled, {
710
+ plugins: options.plugins,
711
+ html: options.html,
712
+ sanitizeUrl: options.sanitizeUrl,
713
+ headingIds: options.headingIds,
714
+ slugPrefix: options.slugPrefix,
715
+ headingAnchors: options.headingAnchors,
716
+ });
717
+ domRender(vnode);
718
+ if (tables.hydrates.size === 0) return;
719
+ const index = hydrateIndex(docOrCompiled, tables);
720
+ queueMicrotask(() => {
721
+ const marked = options.container.querySelectorAll('[data-md-hydrate]');
722
+ for (const el of marked) {
723
+ const name = el.getAttribute('data-md-hydrate');
724
+ const hash = el.getAttribute('data-md-hash') ?? '';
725
+ if (hydrated.get(el) === hash) continue;
726
+ const entry = index.get(hash);
727
+ const plugin = entry !== undefined ? tables.hydrates.get(entry.type) : undefined;
728
+ if (plugin === undefined || plugin.name !== name) continue;
729
+ hydrated.set(el, hash);
730
+ try {
731
+ const result = plugin.hydrate(el, entry, { options, hash: hashContent });
732
+ if (result !== undefined && result !== null && typeof result.catch === 'function') {
733
+ result.catch(onHydrateError);
734
+ }
735
+ }
736
+ catch (err) {
737
+ onHydrateError(err);
738
+ }
739
+ }
740
+ });
741
+ };
742
+ }
743
+
744
+ /**
745
+ * Build (and memoize on the tables) the content-hash → node index for
746
+ * hydratable node types.
747
+ * @param {any} docOrCompiled
748
+ * @param {any} tables
749
+ * @returns {Map<string, MdNode>}
750
+ */
751
+ function hydrateIndex(docOrCompiled, tables) {
752
+ const ast = Array.isArray(docOrCompiled) ? docOrCompiled : docOrCompiled.ast;
753
+ /** @type {WeakMap<any, Map<string, MdNode>>} */
754
+ const memo = tables.hydrateMemo;
755
+ let index = memo.get(ast);
756
+ if (index !== undefined) return index;
757
+ index = new Map();
758
+ walkAst(ast, (node) => {
759
+ if (tables.hydrates.has(node.type) && typeof node.value === 'string') {
760
+ /** @type {Map<string, MdNode>} */ (index).set(hashContent(node.value), node);
761
+ }
762
+ });
763
+ memo.set(ast, index);
764
+ return index;
765
+ }
766
+