@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
package/src/ast.js ADDED
@@ -0,0 +1,346 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The Markdown AST vocabulary: node constructors, shape guards
4
+ * and compiled walkers.
5
+ *
6
+ * Every node is a plain JSON object with a `type` discriminator;
7
+ * container nodes hold ordered content in `children`, literal nodes in
8
+ * `value` (normative vocabulary in docs/MD-FORMAT.md §4). The
9
+ * constructors exist so every node of a type is born with the same
10
+ * hidden class — the walkers and render tables then stay monomorphic.
11
+ */
12
+
13
+ /** The document format version this package produces. */
14
+ export const MD_VERSION = '0.1';
15
+
16
+ /** Node types whose content lives in `children`. */
17
+ const CONTAINER_TYPES = new Set([
18
+ 'paragraph', 'heading', 'blockquote', 'list', 'listItem',
19
+ 'table', 'tableRow', 'tableCell', 'footnoteDefinition',
20
+ 'emphasis', 'strong', 'strikethrough', 'link',
21
+ ]);
22
+
23
+ /**
24
+ * @typedef {{ type: string, [member: string]: any }} MdNode
25
+ */
26
+ /**
27
+ * @typedef {{ $md: string, frontmatter: any, ast: MdNode[],
28
+ * meta: { sourceUrl: string|null, hash: string,
29
+ * frontmatterLang: 'yaml'|'json'|'toml'|null } }} MdDocument
30
+ */
31
+
32
+ /**
33
+ * Does this node hold its content in `children`? Plugin/custom nodes
34
+ * count when they actually carry a children array.
35
+ * @param {MdNode} node
36
+ * @returns {boolean}
37
+ */
38
+ export function isContainerNode(node) {
39
+ return CONTAINER_TYPES.has(node.type) || Array.isArray(node.children);
40
+ }
41
+
42
+ /** @param {MdNode[]} children @returns {MdNode} */
43
+ export function paragraph(children) {
44
+ return { type: 'paragraph', children };
45
+ }
46
+
47
+ /** @param {number} depth @param {MdNode[]} children @returns {MdNode} */
48
+ export function heading(depth, children) {
49
+ return { type: 'heading', depth, children };
50
+ }
51
+
52
+ /** @returns {MdNode} */
53
+ export function thematicBreak() {
54
+ return { type: 'thematicBreak' };
55
+ }
56
+
57
+ /** @param {MdNode[]} children @returns {MdNode} */
58
+ export function blockquote(children) {
59
+ return { type: 'blockquote', children };
60
+ }
61
+
62
+ /**
63
+ * @param {boolean} ordered
64
+ * @param {number|null} start
65
+ * @param {boolean} tight
66
+ * @param {MdNode[]} children
67
+ * @returns {MdNode}
68
+ */
69
+ export function list(ordered, start, tight, children) {
70
+ return { type: 'list', ordered, start, tight, children };
71
+ }
72
+
73
+ /** @param {boolean|null} checked @param {MdNode[]} children @returns {MdNode} */
74
+ export function listItem(checked, children) {
75
+ return { type: 'listItem', checked, children };
76
+ }
77
+
78
+ /**
79
+ * @param {string|null} lang
80
+ * @param {string|null} meta
81
+ * @param {string} value
82
+ * @returns {MdNode}
83
+ */
84
+ export function code(lang, meta, value) {
85
+ return { type: 'code', lang, meta, value };
86
+ }
87
+
88
+ /** @param {string} value @returns {MdNode} */
89
+ export function htmlBlock(value) {
90
+ return { type: 'html', value };
91
+ }
92
+
93
+ /**
94
+ * @param {(string|null)[]} align
95
+ * @param {MdNode[]} children
96
+ * @returns {MdNode}
97
+ */
98
+ export function table(align, children) {
99
+ return { type: 'table', align, children };
100
+ }
101
+
102
+ /** @param {MdNode[]} children @returns {MdNode} */
103
+ export function tableRow(children) {
104
+ return { type: 'tableRow', children };
105
+ }
106
+
107
+ /** @param {MdNode[]} children @returns {MdNode} */
108
+ export function tableCell(children) {
109
+ return { type: 'tableCell', children };
110
+ }
111
+
112
+ /** @param {string} value @returns {MdNode} */
113
+ export function text(value) {
114
+ return { type: 'text', value };
115
+ }
116
+
117
+ /** @param {MdNode[]} children @returns {MdNode} */
118
+ export function emphasis(children) {
119
+ return { type: 'emphasis', children };
120
+ }
121
+
122
+ /** @param {MdNode[]} children @returns {MdNode} */
123
+ export function strong(children) {
124
+ return { type: 'strong', children };
125
+ }
126
+
127
+ /** @param {MdNode[]} children @returns {MdNode} */
128
+ export function strikethrough(children) {
129
+ return { type: 'strikethrough', children };
130
+ }
131
+
132
+ /**
133
+ * @param {string} url
134
+ * @param {string|null} title
135
+ * @param {MdNode[]} children
136
+ * @returns {MdNode}
137
+ */
138
+ export function link(url, title, children) {
139
+ return { type: 'link', url, title, children };
140
+ }
141
+
142
+ /**
143
+ * A GFM literal autolink (`www.example.com`, `a@b.test`): a `link`, not
144
+ * a type of its own — every consumer, plugin and schema would otherwise
145
+ * have to learn a second spelling of the same thing. The `auto` flag is
146
+ * carried for the ONE consumer that has to tell them apart, the
147
+ * canonical printer, which prints it back bare (MD-FORMAT.md §4.7).
148
+ * @param {string} url the resolved destination (scheme inserted)
149
+ * @param {string} literal the text as the author wrote it
150
+ * @returns {MdNode}
151
+ */
152
+ export function autolink(url, literal) {
153
+ return { type: 'link', url, title: null, children: [{ type: 'text', value: literal }], auto: true };
154
+ }
155
+
156
+ /**
157
+ * A GFM footnote definition: block content collected out of the flow
158
+ * and rendered once, at the end, if something cites it.
159
+ * @param {string} identifier the normalized label (matching key)
160
+ * @param {string} label the label as written
161
+ * @param {MdNode[]} children
162
+ * @returns {MdNode}
163
+ */
164
+ export function footnoteDefinition(identifier, label, children) {
165
+ return { type: 'footnoteDefinition', identifier, label, children };
166
+ }
167
+
168
+ /**
169
+ * A GFM footnote reference: the citation mark in the text.
170
+ * @param {string} identifier the normalized label (matching key)
171
+ * @param {string} label the label as written
172
+ * @returns {MdNode}
173
+ */
174
+ export function footnoteReference(identifier, label) {
175
+ return { type: 'footnoteReference', identifier, label };
176
+ }
177
+
178
+ /**
179
+ * @param {string} url
180
+ * @param {string|null} title
181
+ * @param {string} alt
182
+ * @returns {MdNode}
183
+ */
184
+ export function image(url, title, alt) {
185
+ return { type: 'image', url, title, alt };
186
+ }
187
+
188
+ /** @param {string} value @returns {MdNode} */
189
+ export function inlineCode(value) {
190
+ return { type: 'inlineCode', value };
191
+ }
192
+
193
+ /** @returns {MdNode} */
194
+ export function hardBreak() {
195
+ return { type: 'break' };
196
+ }
197
+
198
+ /** @returns {MdNode} */
199
+ export function softBreak() {
200
+ return { type: 'softBreak' };
201
+ }
202
+
203
+ /**
204
+ * The generic escape hatch for constructs without a compiled-in plugin
205
+ * vocabulary (MD-FORMAT §4.4).
206
+ * @param {string} name
207
+ * @param {any} data
208
+ * @param {MdNode[]} [children]
209
+ * @returns {MdNode}
210
+ */
211
+ export function custom(name, data, children = undefined) {
212
+ return children === undefined
213
+ ? { type: 'custom', name, data }
214
+ : { type: 'custom', name, data, children };
215
+ }
216
+
217
+ /**
218
+ * Walk an AST (a node or an array of nodes) in document order, calling
219
+ * `visitor(node, parent, index)` pre-order. Returning `false` from the
220
+ * visitor skips the node's children.
221
+ *
222
+ * @param {MdNode | MdNode[]} root
223
+ * @param {(node: MdNode, parent: MdNode|null, index: number) => (boolean|void)} visitor
224
+ */
225
+ export function walkAst(root, visitor) {
226
+ if (Array.isArray(root)) {
227
+ for (let i = 0; i < root.length; i++) walkNode(root[i], null, i, visitor);
228
+ }
229
+ else {
230
+ walkNode(root, null, 0, visitor);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * @param {MdNode} node
236
+ * @param {MdNode|null} parent
237
+ * @param {number} index
238
+ * @param {(node: MdNode, parent: MdNode|null, index: number) => (boolean|void)} visitor
239
+ */
240
+ function walkNode(node, parent, index, visitor) {
241
+ if (visitor(node, parent, index) === false) return;
242
+ const children = node.children;
243
+ if (Array.isArray(children)) {
244
+ for (let i = 0; i < children.length; i++) {
245
+ walkNode(children[i], node, i, visitor);
246
+ }
247
+ }
248
+ }
249
+
250
+ /**
251
+ * @typedef {(node: MdNode, parent: MdNode|null, index: number) => (boolean|void)} MdVisitFn
252
+ * @typedef {MdVisitFn | { enter?: MdVisitFn, exit?: MdVisitFn }} MdVisitSpec
253
+ */
254
+
255
+ /**
256
+ * Compile a per-type visitor spec into a dispatch table and walk with
257
+ * it. Handlers are keyed by node type, `'*'` matches every type; a
258
+ * handler is a function (pre-order) or `{ enter, exit }`. An `enter`
259
+ * returning `false` skips the children (exit still runs).
260
+ *
261
+ * The table is built once per call — pass the same spec object to reuse
262
+ * the compiled form across documents (a `WeakMap` memo keeps this
263
+ * allocation-free on repeat visits).
264
+ *
265
+ * @param {MdNode | MdNode[]} root
266
+ * @param {Record<string, MdVisitSpec>} visitors
267
+ */
268
+ export function visitAst(root, visitors) {
269
+ const table = compileVisitorTable(visitors);
270
+ if (Array.isArray(root)) {
271
+ for (let i = 0; i < root.length; i++) visitNode(root[i], null, i, table);
272
+ }
273
+ else {
274
+ visitNode(root, null, 0, table);
275
+ }
276
+ }
277
+
278
+ /** @type {WeakMap<object, { enter: Record<string, MdVisitFn>, exit: Record<string, MdVisitFn>, any: { enter: MdVisitFn|null, exit: MdVisitFn|null } }>} */
279
+ const visitorMemo = new WeakMap();
280
+
281
+ /**
282
+ * @param {Record<string, MdVisitSpec>} visitors
283
+ */
284
+ function compileVisitorTable(visitors) {
285
+ let table = visitorMemo.get(visitors);
286
+ if (table !== undefined) return table;
287
+ /** @type {Record<string, MdVisitFn>} */
288
+ const enter = Object.create(null);
289
+ /** @type {Record<string, MdVisitFn>} */
290
+ const exit = Object.create(null);
291
+ const any = { enter: /** @type {MdVisitFn|null} */ (null), exit: /** @type {MdVisitFn|null} */ (null) };
292
+ for (const type of Object.keys(visitors)) {
293
+ const spec = visitors[type];
294
+ const enterFn = typeof spec === 'function' ? spec : spec.enter ?? null;
295
+ const exitFn = typeof spec === 'function' ? null : spec.exit ?? null;
296
+ if (type === '*') {
297
+ any.enter = enterFn;
298
+ any.exit = exitFn;
299
+ }
300
+ else {
301
+ if (enterFn !== null) enter[type] = enterFn;
302
+ if (exitFn !== null) exit[type] = exitFn;
303
+ }
304
+ }
305
+ table = { enter, exit, any };
306
+ visitorMemo.set(visitors, table);
307
+ return table;
308
+ }
309
+
310
+ /**
311
+ * @param {MdNode} node
312
+ * @param {MdNode|null} parent
313
+ * @param {number} index
314
+ * @param {{ enter: Record<string, MdVisitFn>, exit: Record<string, MdVisitFn>, any: { enter: MdVisitFn|null, exit: MdVisitFn|null } }} table
315
+ */
316
+ function visitNode(node, parent, index, table) {
317
+ const enterFn = table.enter[node.type] ?? table.any.enter;
318
+ let descend = true;
319
+ if (enterFn !== null && enterFn !== undefined) {
320
+ descend = enterFn(node, parent, index) !== false;
321
+ }
322
+ if (descend && Array.isArray(node.children)) {
323
+ const children = node.children;
324
+ for (let i = 0; i < children.length; i++) {
325
+ visitNode(children[i], node, i, table);
326
+ }
327
+ }
328
+ const exitFn = table.exit[node.type] ?? table.any.exit;
329
+ if (exitFn !== null && exitFn !== undefined) exitFn(node, parent, index);
330
+ }
331
+
332
+ /**
333
+ * The plain-text content of a node subtree (alt-text derivation,
334
+ * heading slugs, search indexing).
335
+ * @param {MdNode | MdNode[]} root
336
+ * @returns {string}
337
+ */
338
+ export function textOf(root) {
339
+ let out = '';
340
+ walkAst(root, (node) => {
341
+ if (node.type === 'text' || node.type === 'inlineCode') out += node.value;
342
+ else if (node.type === 'image') out += node.alt;
343
+ else if (node.type === 'break' || node.type === 'softBreak') out += ' ';
344
+ });
345
+ return out;
346
+ }
package/src/bake.js ADDED
@@ -0,0 +1,104 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `bake` — write a derived value back into the source text.
4
+ *
5
+ * A directive carries a value a machine derives and a human reads
6
+ * (directives.js). Baking materializes the current value INTO the
7
+ * committed file, which is the whole point: the document needs no
8
+ * runtime, GitHub renders it correctly, and the next re-derivation shows
9
+ * up as a diff a reviewer can look at instead of a number that quietly
10
+ * stopped being true.
11
+ *
12
+ * Two properties make it usable on documents people wrote by hand:
13
+ *
14
+ * - **byte-local.** Only the spans between markers change. `toMarkdown`
15
+ * is a canonicalizing printer, so re-printing a README would reflow
16
+ * every list and re-wrap every table — correct markdown, and a diff
17
+ * nobody can review. So `bake` splices the SOURCE, and a document
18
+ * with no directives comes back the same bytes it went in as.
19
+ * - **idempotent.** Baking an already-baked document changes nothing,
20
+ * which is what lets a `--check` mode be "bake and compare".
21
+ *
22
+ * The trust level is different from mdx's, and the difference is the
23
+ * point: an mdx interpolation lands in a TEXT node and is never re-read
24
+ * as markdown, because it may carry untrusted data. A baked body is
25
+ * spliced into the source and WILL be re-parsed as markdown — a fact
26
+ * that is a whole table is exactly the use case. `bake` is therefore a
27
+ * build-time tool for input you control, and it says so here rather than
28
+ * leaving the two to be confused.
29
+ */
30
+
31
+ import { parseMarkdown } from './parser.js';
32
+ import { scanDirectives, scanSourceDirectives } from './directives.js';
33
+
34
+ /**
35
+ * @typedef {{ text: string, changed: boolean, diagnostics: string[],
36
+ * applied: { key: string, from: string, to: string }[] }} BakeResult
37
+ */
38
+
39
+ /**
40
+ * Replace every directive body in `source` with a freshly resolved
41
+ * value.
42
+ *
43
+ * `resolve(key, directive)` returns the text to place between the
44
+ * markers, or `undefined` to leave that directive untouched. Throwing is
45
+ * allowed and is reported as a diagnostic — one bad derivation must not
46
+ * cost the rest of the document.
47
+ *
48
+ * @param {string} source the document text
49
+ * @param {{ ns: string, resolve: (key: string, directive: any) => (string|undefined),
50
+ * parseOptions?: any }} options
51
+ * @returns {BakeResult}
52
+ */
53
+ export function bake(source, options) {
54
+ const ns = options.ns;
55
+ const resolve = options.resolve;
56
+ /** @type {string[]} */
57
+ const diagnostics = [];
58
+ /** @type {{ key: string, from: string, to: string }[]} */
59
+ const applied = [];
60
+
61
+ const found = scanSourceDirectives(source, { ns });
62
+ diagnostics.push(...found.diagnostics);
63
+
64
+ // The source says where a directive lives; the PARSED DOCUMENT says
65
+ // whether the renderer will see one there. They disagree in exactly
66
+ // one situation that matters, and it is a trap worth naming loudly:
67
+ // a marker that BEGINS A LINE inside a paragraph opens a CommonMark
68
+ // HTML block, which swallows the rest of that line. The bytes still
69
+ // bake correctly and the file still looks right — and every renderer
70
+ // then drops or escapes the whole line, so the sentence the marker
71
+ // was carrying a number for disappears. Bake it, and say so.
72
+ const parsed = scanDirectives(
73
+ parseMarkdown(source, { ...options.parseOptions, frontmatter: false }), { ns });
74
+ diagnostics.push(...parsed.diagnostics.filter((d) => !found.diagnostics.includes(d)));
75
+ let seen = 0;
76
+ for (const directive of found.directives) {
77
+ if (seen < parsed.directives.length && parsed.directives[seen].key === directive.key) {
78
+ seen++;
79
+ continue;
80
+ }
81
+ diagnostics.push(`${directive.key}: the marker is not an inline directive in the parsed`
82
+ + ' document — a marker that starts a line opens an HTML block and swallows the rest'
83
+ + ' of that line. Put text before it, or give it a paragraph of its own.');
84
+ }
85
+
86
+ // right to left, so each splice leaves earlier offsets valid
87
+ let text = source;
88
+ for (let i = found.directives.length - 1; i >= 0; i--) {
89
+ const directive = found.directives[i];
90
+ let value;
91
+ try {
92
+ value = resolve(directive.key, directive);
93
+ }
94
+ catch (err) {
95
+ diagnostics.push(`${directive.key}: resolver threw — ${String(/** @type {any} */ (err)?.message ?? err)}`);
96
+ continue;
97
+ }
98
+ if (value === undefined) continue;
99
+ if (value === directive.body) continue;
100
+ applied.unshift({ key: directive.key, from: directive.body, to: value });
101
+ text = text.slice(0, directive.bodyStart) + value + text.slice(directive.bodyEnd);
102
+ }
103
+ return { text, changed: text !== source, diagnostics, applied };
104
+ }
@@ -0,0 +1,167 @@
1
+ //@ts-check
2
+ /**
3
+ * @file compileMarkdown: parse once, then hand out specialized
4
+ * closures.
5
+ *
6
+ * A CompiledMd is the package's unit of work: the parsed document plus
7
+ * lazily-built, cached projections (vnode tree, canonical Markdown,
8
+ * frontmatter externals). Every projection is computed at most once
9
+ * per compiled document — calling `toVnode()` twice returns the same
10
+ * reference, which is what the view patcher's `===` fast path wants.
11
+ */
12
+
13
+ import { parseMarkdown, buildPluginTables } from './parser.js';
14
+ import { walkAst, visitAst } from './ast.js';
15
+ import { toMarkdown } from './to-md.js';
16
+ import { mdToVnode } from './to-vnode.js';
17
+
18
+ /**
19
+ * @typedef {import('./ast.js').MdNode} MdNode
20
+ * @typedef {import('./ast.js').MdDocument} MdDocument
21
+ * @typedef {import('./parser.js').MdParseOptions} MdParseOptions
22
+ */
23
+ /**
24
+ * @typedef {MdParseOptions & { retainSource?: boolean,
25
+ * html?: 'skip'|'text', headingIds?: boolean, slugPrefix?: string,
26
+ * headingAnchors?: boolean, footnotesLabel?: string,
27
+ * keyed?: boolean }} MdCompileOptions
28
+ */
29
+ /**
30
+ * The compiled closure bundle.
31
+ * @typedef {object} CompiledMd
32
+ * @property {MdDocument} doc the parsed document
33
+ * @property {MdNode[]} ast `doc.ast`
34
+ * @property {any} frontmatter `doc.frontmatter`
35
+ * @property {string} hash `doc.meta.hash`
36
+ * @property {string|null} source the source text (null when `retainSource: false`)
37
+ * @property {any} tables the compiled plugin tables (internal contract)
38
+ * @property {() => any} toVnode cached vnode projection
39
+ * @property {() => string} toMarkdown cached canonical Markdown
40
+ * @property {(visitor: any) => void} walk pre-order walker over the AST
41
+ * @property {(visitors: any) => void} visit compiled per-type visitor
42
+ * @property {() => Record<string, any>} externals frontmatter as JSLT/query externals
43
+ */
44
+
45
+ /**
46
+ * Compile Markdown source (or an already-parsed MdDocument) into a
47
+ * closure bundle.
48
+ *
49
+ * @example
50
+ * const md = compileMarkdown('# Hi\n\nSome *text*.');
51
+ * md.toVnode(); // ['article', { class: 'md' }, ...]
52
+ * md.toMarkdown(); // '# Hi\n\nSome *text*.\n'
53
+ * md.externals(); // {} — no frontmatter
54
+ *
55
+ * @param {string | MdDocument} sourceOrDoc
56
+ * @param {MdCompileOptions} [options]
57
+ * @returns {CompiledMd}
58
+ */
59
+ export function compileMarkdown(sourceOrDoc, options = {}) {
60
+ const fromSource = typeof sourceOrDoc === 'string';
61
+ const doc = fromSource
62
+ ? parseMarkdown(sourceOrDoc, options)
63
+ : sourceOrDoc;
64
+ const source = fromSource && options.retainSource !== false
65
+ ? /** @type {string} */ (sourceOrDoc)
66
+ : null;
67
+ const tables = buildPluginTables(options.plugins);
68
+
69
+ /** @type {any} */
70
+ let vnode;
71
+ /** @type {string | undefined} */
72
+ let markdown;
73
+ /** @type {Record<string, any> | undefined} */
74
+ let externals;
75
+
76
+ /** @type {CompiledMd} */
77
+ const compiled = {
78
+ doc,
79
+ ast: doc.ast,
80
+ frontmatter: doc.frontmatter,
81
+ hash: doc.meta.hash,
82
+ source,
83
+ tables,
84
+
85
+ toVnode() {
86
+ if (vnode === undefined) {
87
+ vnode = mdToVnode(compiled, {
88
+ plugins: options.plugins,
89
+ html: options.html,
90
+ sanitizeUrl: options.sanitizeUrl,
91
+ headingIds: options.headingIds,
92
+ slugPrefix: options.slugPrefix,
93
+ headingAnchors: options.headingAnchors,
94
+ footnotesLabel: options.footnotesLabel,
95
+ keyed: options.keyed,
96
+ });
97
+ }
98
+ return vnode;
99
+ },
100
+
101
+ toMarkdown() {
102
+ if (markdown === undefined) markdown = toMarkdown(doc);
103
+ return markdown;
104
+ },
105
+
106
+ walk(visitor) {
107
+ walkAst(doc.ast, visitor);
108
+ },
109
+
110
+ visit(visitors) {
111
+ visitAst(doc.ast, visitors);
112
+ },
113
+
114
+ externals() {
115
+ if (externals === undefined) externals = frontmatterExternals(doc.frontmatter);
116
+ return externals;
117
+ },
118
+ };
119
+ return compiled;
120
+ }
121
+
122
+ /**
123
+ * Flatten frontmatter into an externals object for the query/JSLT
124
+ * engines (MD-FORMAT.md §3.4). Only a frontmatter *object* contributes
125
+ * members; the engine-reserved names `root` and `path` are dropped.
126
+ * @param {any} frontmatter
127
+ * @returns {Record<string, any>}
128
+ */
129
+ export function frontmatterExternals(frontmatter) {
130
+ /** @type {Record<string, any>} */
131
+ const out = {};
132
+ if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
133
+ return out;
134
+ }
135
+ for (const key of Object.keys(frontmatter)) {
136
+ if (key === 'root' || key === 'path') continue;
137
+ out[key] = frontmatter[key];
138
+ }
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * Emit a `@jarenjs/forms`-consumable structure from a document whose
144
+ * frontmatter declares a schema (`$schema` object member or `form:`
145
+ * key). The caller injects the forms module (or the two functions it
146
+ * needs) — `@jarenjs/md` stays dependency-free:
147
+ *
148
+ * @example
149
+ * import * as forms from '@jarenjs/forms';
150
+ * const form = mdToForm(md.doc, forms);
151
+ * // { schema, fields, data } or null when no schema is declared
152
+ *
153
+ * @param {MdDocument} doc
154
+ * @param {{ buildFormModel: (schema: any) => any,
155
+ * createInitialData: (schema: any) => any }} forms
156
+ * @returns {{ schema: any, fields: any, data: any } | null}
157
+ */
158
+ export function mdToForm(doc, forms) {
159
+ const fm = doc.frontmatter;
160
+ if (fm === null || typeof fm !== 'object' || Array.isArray(fm)) return null;
161
+ const schema = typeof fm.form === 'object' && fm.form !== null
162
+ ? fm.form
163
+ : typeof fm.$schema === 'object' && fm.$schema !== null ? fm.$schema : null;
164
+ if (schema === null) return null;
165
+ const data = fm.data !== undefined ? fm.data : forms.createInitialData(schema);
166
+ return { schema, fields: forms.buildFormModel(schema), data };
167
+ }