@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.
- package/README.md +520 -0
- package/dist/types/ast.d.ts +181 -0
- package/dist/types/bake.d.ts +61 -0
- package/dist/types/compiler.d.ts +141 -0
- package/dist/types/component/index.d.ts +101 -0
- package/dist/types/directives.d.ts +126 -0
- package/dist/types/entities.d.ts +40 -0
- package/dist/types/footnotes.d.ts +83 -0
- package/dist/types/frontmatter.d.ts +67 -0
- package/dist/types/html.d.ts +72 -0
- package/dist/types/index.d.ts +30 -0
- package/dist/types/loader.d.ts +84 -0
- package/dist/types/mdx.d.ts +45 -0
- package/dist/types/parser.d.ts +116 -0
- package/dist/types/plugins/highlight.d.ts +64 -0
- package/dist/types/plugins/index.d.ts +64 -0
- package/dist/types/plugins/mermaid.d.ts +12 -0
- package/dist/types/scanner.d.ts +240 -0
- package/dist/types/to-html.d.ts +104 -0
- package/dist/types/to-md.d.ts +23 -0
- package/dist/types/to-vnode.d.ts +161 -0
- package/dist/types/utils.d.ts +63 -0
- package/docs/LOADER.md +92 -0
- package/docs/MD-FORMAT.md +502 -0
- package/docs/PLUGINS.md +277 -0
- package/package.json +80 -0
- package/schemas/jaren-md-ast.schema.json +296 -0
- package/src/ast.js +346 -0
- package/src/bake.js +104 -0
- package/src/compiler.js +167 -0
- package/src/component/index.js +191 -0
- package/src/directives.js +371 -0
- package/src/entities.js +107 -0
- package/src/footnotes.js +180 -0
- package/src/frontmatter.js +947 -0
- package/src/html.js +281 -0
- package/src/index.js +76 -0
- package/src/loader.js +0 -0
- package/src/mdx.js +219 -0
- package/src/parser.js +1685 -0
- package/src/plugins/highlight.js +325 -0
- package/src/plugins/index.js +75 -0
- package/src/plugins/mermaid.js +14 -0
- package/src/scanner.js +832 -0
- package/src/to-html.js +425 -0
- package/src/to-md.js +396 -0
- package/src/to-vnode.js +766 -0
- package/src/utils.js +107 -0
- package/styles/md.css +238 -0
package/src/to-vnode.js
ADDED
|
@@ -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
|
+
|