@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/html.js ADDED
@@ -0,0 +1,281 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Raw HTML in Markdown, parsed to vnodes through an allow-list.
4
+ *
5
+ * The vnode format has no unescaped output — deliberately: a tree of
6
+ * arrays cannot carry a half-open tag or an injected `<script>`, which
7
+ * is why `mdToVnode` has always either dropped raw HTML or shown it as
8
+ * text. This module is the third answer: PARSE the HTML and keep what an
9
+ * allow-list recognises, so `<details>` and `<img>` in a document render
10
+ * as themselves.
11
+ *
12
+ * The safety argument is structural rather than a promise about
13
+ * filtering strings. The output is a vnode tree, so:
14
+ *
15
+ * - an element not on the list contributes nothing but its children's
16
+ * text — there is no path by which its markup reaches the DOM;
17
+ * - an attribute not on the list is dropped, so `on*` handlers,
18
+ * `style` and `srcdoc` never exist to begin with;
19
+ * - `href`/`src` go through the same {@link sanitizeUrl} policy as
20
+ * Markdown's own links, so `javascript:` cannot ride in on raw HTML
21
+ * when it cannot ride in on `[x](…)`.
22
+ *
23
+ * What this is NOT: an HTML5 parser. It does not implement implicit
24
+ * end tags, foster parenting, or the tokenizer's error recovery, and it
25
+ * does not attempt to reproduce what a browser would build from
26
+ * malformed input. Unbalanced input closes at the end of the fragment.
27
+ * For hostile input the goal is that nothing survives that should not,
28
+ * not that the shape matches what a browser would have made of it.
29
+ *
30
+ * A host that wants different rules injects its own parser instead
31
+ * (`mdToVnode`'s `parseHtml` option) — this is the default, not the
32
+ * only, implementation.
33
+ */
34
+
35
+ import { sanitizeUrl } from '@jarenjs/view/helpers';
36
+ import { decodeReferences } from './entities.js';
37
+
38
+ /** Elements that carry no content and never take an end tag. */
39
+ const VOID = new Set([
40
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
41
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
42
+ ]);
43
+
44
+ /**
45
+ * The elements a Markdown document may render. Structure, text and
46
+ * media; nothing that scripts, loads a document, or takes over the page
47
+ * (`script`, `style`, `iframe`, `object`, `form`, `input`).
48
+ */
49
+ const ELEMENTS = new Set([
50
+ 'a', 'abbr', 'b', 'bdi', 'bdo', 'blockquote', 'br', 'caption', 'cite',
51
+ 'code', 'col', 'colgroup', 'dd', 'del', 'details', 'dfn', 'div', 'dl',
52
+ 'dt', 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
53
+ 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'mark', 'ol', 'p', 'picture',
54
+ 'pre', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'small',
55
+ 'source', 'span', 'strong', 'sub', 'summary', 'sup', 'table', 'tbody',
56
+ 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u', 'ul', 'var', 'wbr',
57
+ ]);
58
+
59
+ /**
60
+ * Elements whose CONTENT is not prose: dropping the tag and keeping the
61
+ * children would print a script's source as text. Their subtree goes.
62
+ */
63
+ const DROP_CONTENT = new Set(['script', 'style', 'textarea', 'title', 'noscript', 'template']);
64
+
65
+ /** Attributes allowed on any element. */
66
+ const GLOBAL_ATTRS = new Set(['class', 'id', 'title', 'lang', 'dir', 'role']);
67
+
68
+ /** Attributes allowed on specific elements. */
69
+ const ELEMENT_ATTRS = {
70
+ a: new Set(['href', 'name', 'target', 'rel', 'download']),
71
+ img: new Set(['src', 'alt', 'width', 'height', 'loading', 'decoding', 'srcset', 'sizes']),
72
+ source: new Set(['src', 'srcset', 'sizes', 'type', 'media']),
73
+ col: new Set(['span']),
74
+ colgroup: new Set(['span']),
75
+ td: new Set(['colspan', 'rowspan', 'headers', 'align']),
76
+ th: new Set(['colspan', 'rowspan', 'headers', 'scope', 'abbr', 'align']),
77
+ ol: new Set(['start', 'reversed', 'type']),
78
+ li: new Set(['value']),
79
+ details: new Set(['open']),
80
+ time: new Set(['datetime']),
81
+ del: new Set(['cite', 'datetime']),
82
+ ins: new Set(['cite', 'datetime']),
83
+ blockquote: new Set(['cite']),
84
+ q: new Set(['cite']),
85
+ };
86
+
87
+ /** Attributes holding a URL, filtered by the Markdown link policy. */
88
+ const URL_ATTRS = new Set(['href', 'src']);
89
+
90
+ /**
91
+ * @typedef {object} HtmlParseOptions
92
+ * @property {(url: string) => (string|null)} [sanitizeUrl] URL policy for
93
+ * `href`/`src` (default: the shared deny-list, as Markdown links use)
94
+ */
95
+
96
+ /**
97
+ * Parse an HTML fragment into vnodes, keeping only what the allow-list
98
+ * recognises.
99
+ *
100
+ * @param {string} source the raw HTML of one Markdown html node
101
+ * @param {HtmlParseOptions} [options]
102
+ * @returns {any[]} vnodes and strings; empty when nothing survived
103
+ * @example
104
+ * parseHtmlFragment('<details><summary>More</summary>text</details>');
105
+ * // [['details', {}, ['summary', {}, 'More'], 'text']]
106
+ */
107
+ export function parseHtmlFragment(source, options = {}) {
108
+ const urlPolicy = options.sanitizeUrl ?? sanitizeUrl;
109
+ /** Open elements; the root frame collects the result. */
110
+ const stack = [{ tag: '', children: /** @type {any[]} */ ([]) }];
111
+ let i = 0;
112
+ let text = '';
113
+
114
+ const flushText = () => {
115
+ if (text === '') return;
116
+ stack[stack.length - 1].children.push(decodeReferences(text));
117
+ text = '';
118
+ };
119
+
120
+ while (i < source.length) {
121
+ if (source.charCodeAt(i) !== 0x3C /* < */) {
122
+ text += source[i];
123
+ i++;
124
+ continue;
125
+ }
126
+ // A comment, doctype or processing instruction carries no content
127
+ // worth keeping — and its text must not leak out as prose either.
128
+ if (source.startsWith('<!--', i)) {
129
+ const close = source.indexOf('-->', i + 4);
130
+ i = close === -1 ? source.length : close + 3;
131
+ continue;
132
+ }
133
+ if (source.startsWith('<!', i) || source.startsWith('<?', i)) {
134
+ const close = source.indexOf('>', i);
135
+ i = close === -1 ? source.length : close + 1;
136
+ continue;
137
+ }
138
+ const tag = scanTag(source, i);
139
+ if (tag === null) {
140
+ // not a tag at all: a literal `<` in prose
141
+ text += source[i];
142
+ i++;
143
+ continue;
144
+ }
145
+ i = tag.end;
146
+ flushText();
147
+ if (tag.closing) {
148
+ // close the nearest matching open element; an end tag that matches
149
+ // nothing is noise and is dropped
150
+ for (let depth = stack.length - 1; depth > 0; depth--) {
151
+ if (stack[depth].tag !== tag.name) continue;
152
+ for (let k = stack.length - 1; k >= depth; k--) closeFrame(stack);
153
+ break;
154
+ }
155
+ continue;
156
+ }
157
+ const known = ELEMENTS.has(tag.name);
158
+ if (known && (tag.selfClosing || VOID.has(tag.name))) {
159
+ stack[stack.length - 1].children.push([tag.name, attrsFor(tag, urlPolicy)]);
160
+ continue;
161
+ }
162
+ // An unknown element still opens a frame: its CHILDREN are content
163
+ // the reader wrote and should keep, its own markup is what goes.
164
+ stack.push({
165
+ tag: tag.name,
166
+ children: [],
167
+ props: known ? attrsFor(tag, urlPolicy) : null,
168
+ keep: known && !tag.selfClosing,
169
+ drop: DROP_CONTENT.has(tag.name),
170
+ });
171
+ }
172
+ flushText();
173
+ while (stack.length > 1) closeFrame(stack);
174
+ return stack[0].children;
175
+ }
176
+
177
+ /**
178
+ * Classify ONE tag — what CommonMark's inline phase hands out, since at
179
+ * that level a tag is not an element (`<b>bold</b>` is three siblings).
180
+ * Returns null when the source is not exactly one tag.
181
+ * @param {string} source
182
+ * @param {HtmlParseOptions} [options]
183
+ * @returns {{name: string, closing: boolean, complete: boolean,
184
+ * drop: boolean, props: Record<string, any>|null} | null}
185
+ */
186
+ export function parseHtmlTag(source, options = {}) {
187
+ const tag = scanTag(source, 0);
188
+ if (tag === null || tag.end !== source.length) return null;
189
+ const known = ELEMENTS.has(tag.name);
190
+ return {
191
+ name: tag.name,
192
+ closing: tag.closing,
193
+ // a void element or an explicit `<x/>` never takes an end tag
194
+ complete: tag.selfClosing || VOID.has(tag.name),
195
+ drop: DROP_CONTENT.has(tag.name),
196
+ props: known ? attrsFor(tag, options.sanitizeUrl ?? sanitizeUrl) : null,
197
+ };
198
+ }
199
+
200
+ /** Pop the innermost frame into its parent, dropping unknown elements. */
201
+ function closeFrame(stack) {
202
+ const frame = stack.pop();
203
+ const parent = stack[stack.length - 1].children;
204
+ if (frame.drop) return;
205
+ if (frame.keep) parent.push([frame.tag, frame.props, ...frame.children]);
206
+ else parent.push(...frame.children);
207
+ }
208
+
209
+ /** The allow-listed attributes of one tag, as vnode props. */
210
+ function attrsFor(tag, urlPolicy) {
211
+ const allowed = ELEMENT_ATTRS[tag.name];
212
+ /** @type {Record<string, any>} */
213
+ const props = {};
214
+ for (const [name, value] of tag.attrs) {
215
+ const lower = name.toLowerCase();
216
+ if (!GLOBAL_ATTRS.has(lower) && (allowed === undefined || !allowed.has(lower))) continue;
217
+ if (URL_ATTRS.has(lower)) {
218
+ const safe = urlPolicy(decodeReferences(value));
219
+ if (safe !== null) props[lower] = safe;
220
+ continue;
221
+ }
222
+ props[lower] = decodeReferences(value);
223
+ }
224
+ return props;
225
+ }
226
+
227
+ /** Tag-name characters: a letter, then letters, digits or `-`. */
228
+ const RE_TAG = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/;
229
+
230
+ /**
231
+ * Scan one tag at `pos`. Attribute values may be double-quoted,
232
+ * single-quoted or bare; an unterminated tag is not a tag.
233
+ * @returns {{name: string, closing: boolean, selfClosing: boolean,
234
+ * attrs: [string, string][], end: number} | null}
235
+ */
236
+ function scanTag(source, pos) {
237
+ const m = RE_TAG.exec(source.slice(pos, pos + 64));
238
+ if (m === null) return null;
239
+ const closing = m[1] === '/';
240
+ const name = m[2].toLowerCase();
241
+ let i = pos + m[0].length;
242
+ /** @type {[string, string][]} */
243
+ const attrs = [];
244
+ while (i < source.length) {
245
+ while (i < source.length && isSpace(source.charCodeAt(i))) i++;
246
+ const c = source.charCodeAt(i);
247
+ if (c === 0x3E /* > */) return { name, closing, selfClosing: false, attrs, end: i + 1 };
248
+ if (c === 0x2F /* / */ && source.charCodeAt(i + 1) === 0x3E)
249
+ return { name, closing, selfClosing: true, attrs, end: i + 2 };
250
+ const start = i;
251
+ while (i < source.length && !isSpace(source.charCodeAt(i))
252
+ && source.charCodeAt(i) !== 0x3D && source.charCodeAt(i) !== 0x3E
253
+ && source.charCodeAt(i) !== 0x2F) i++;
254
+ if (i === start) return null; // a character no attribute name may start with
255
+ const attrName = source.slice(start, i);
256
+ while (i < source.length && isSpace(source.charCodeAt(i))) i++;
257
+ if (source.charCodeAt(i) !== 0x3D /* = */) {
258
+ attrs.push([attrName, '']);
259
+ continue;
260
+ }
261
+ i++;
262
+ while (i < source.length && isSpace(source.charCodeAt(i))) i++;
263
+ const quote = source.charCodeAt(i);
264
+ if (quote === 0x22 || quote === 0x27) {
265
+ const close = source.indexOf(String.fromCharCode(quote), i + 1);
266
+ if (close === -1) return null;
267
+ attrs.push([attrName, source.slice(i + 1, close)]);
268
+ i = close + 1;
269
+ continue;
270
+ }
271
+ const valueStart = i;
272
+ while (i < source.length && !isSpace(source.charCodeAt(i))
273
+ && source.charCodeAt(i) !== 0x3E) i++;
274
+ attrs.push([attrName, source.slice(valueStart, i)]);
275
+ }
276
+ return null; // never terminated
277
+ }
278
+
279
+ function isSpace(code) {
280
+ return code === 0x20 || code === 0x09 || code === 0x0A || code === 0x0D || code === 0x0C;
281
+ }
package/src/index.js ADDED
@@ -0,0 +1,76 @@
1
+ //@ts-check
2
+ /**
3
+ * @jarenjs/md - Markdown + frontmatter as JSON documents.
4
+ *
5
+ * parseMarkdown turns Markdown (CommonMark core + GFM tables,
6
+ * strikethrough, task lists, footnotes and autolink literals +
7
+ * YAML/JSON/TOML frontmatter) into a plain
8
+ * JSON AST the rest of the suite consumes natively: JSLT/JTLT and
9
+ * query documents transform it, `mdToVnode` projects it to
10
+ * @jarenjs/view vnodes with content-hash keys, `toMarkdown` prints
11
+ * canonical round-trip text, and `loadMarkdown`/`streamMarkdown` pull
12
+ * documents lazily from any URL with caching, AbortSignal and
13
+ * block-by-block streaming. Extensibility is compile-time plugins
14
+ * (`definePlugin`, `@jarenjs/md/plugins`) baked into dispatch tables.
15
+ *
16
+ * The normative contracts: docs/MD-FORMAT.md (AST + frontmatter),
17
+ * docs/PLUGINS.md (plugin system), docs/LOADER.md (loader).
18
+ */
19
+
20
+ export {
21
+ parseMarkdown,
22
+ createIncrementalParser,
23
+ buildPluginTables,
24
+ } from './parser.js';
25
+
26
+ export {
27
+ compileMarkdown,
28
+ frontmatterExternals,
29
+ mdToForm,
30
+ } from './compiler.js';
31
+
32
+ export {
33
+ loadMarkdown,
34
+ streamMarkdown,
35
+ createMdCache,
36
+ defaultMdCache,
37
+ } from './loader.js';
38
+
39
+ export { toMarkdown } from './to-md.js';
40
+
41
+ export { toHtml } from './to-html.js';
42
+
43
+ export {
44
+ mdToVnode,
45
+ createMdRenderer,
46
+ } from './to-vnode.js';
47
+
48
+ export {
49
+ scanDirectives,
50
+ replaceDirectives,
51
+ scanSourceDirectives,
52
+ parseMarker,
53
+ } from './directives.js';
54
+
55
+ export { bake } from './bake.js';
56
+
57
+ export { definePlugin } from './plugins/index.js';
58
+
59
+ export { parseHtmlFragment, parseHtmlTag } from './html.js';
60
+
61
+ export {
62
+ MD_VERSION,
63
+ walkAst,
64
+ visitAst,
65
+ textOf,
66
+ isContainerNode,
67
+ } from './ast.js';
68
+
69
+ export {
70
+ parseFrontmatter,
71
+ parseYamlSubset,
72
+ parseTomlSubset,
73
+ MdFrontmatterError,
74
+ } from './frontmatter.js';
75
+
76
+ export { hashContent } from './utils.js';
package/src/loader.js ADDED
Binary file
package/src/mdx.js ADDED
@@ -0,0 +1,219 @@
1
+ //@ts-check
2
+ /**
3
+ * @file mdx = markdown × data. A markdown document renders DYNAMICALLY
4
+ * against a data document: inline interpolation, conditional sections
5
+ * and repeated sections, all driven by the data — still a pure
6
+ * `(doc, data) → doc` transform over the parsed AST, so everything
7
+ * downstream (`mdToVnode`, `toMarkdown`, the plugins) works unchanged.
8
+ *
9
+ * The template vocabulary reuses the suite's own query expressions —
10
+ * no new mini-language:
11
+ *
12
+ * - `{$.path}` inline in TEXT interpolates a query expression over the
13
+ * data (`$` is the data document; `$name` externals come from the
14
+ * frontmatter and the enclosing `each` bindings). Code spans, code
15
+ * blocks and raw HTML never interpolate.
16
+ * - a paragraph of exactly `{#if <expr>}` … `{/if}` keeps its section
17
+ * only when the expression is truthy (the query engine's boolean
18
+ * view: null/false/''/0/[] are false).
19
+ * - a paragraph of exactly `{#each <expr> as <name>}` … `{/each}`
20
+ * repeats its section once per item, binding each as the external
21
+ * `$<name>`. Sections nest.
22
+ *
23
+ * A directive must form its OWN paragraph — surround it with blank
24
+ * lines, or markdown's lazy continuation folds the next line into it
25
+ * and the directive reads as plain text.
26
+ *
27
+ * The expression COMPILER is injected (`compileJsonQuery` from
28
+ * `@jarenjs/json`), so this package's engine layer keeps its
29
+ * core+view-only dependency contract — the same seam philosophy the
30
+ * play surface uses for its renderers.
31
+ */
32
+
33
+ import { frontmatterExternals } from './compiler.js';
34
+ import { replaceDirectives } from './directives.js';
35
+
36
+ const OPEN_IF = /^\{#if\s+(.+)\}$/;
37
+ const OPEN_EACH = /^\{#each\s+(.+)\s+as\s+([A-Za-z_]\w*)\}$/;
38
+ const CLOSE = /^\{\/(if|each)\}$/;
39
+ const OPEN_ANY = /^\{#(if|each)\b/;
40
+ const INLINE = /\{(\$[^{}]*)\}/g;
41
+
42
+ /** The directive text of a paragraph that is EXACTLY one text node, or null. */
43
+ function directiveText(node) {
44
+ if (node === null || typeof node !== 'object' || node.type !== 'paragraph') return null;
45
+ const children = node.children;
46
+ if (!Array.isArray(children) || children.length !== 1) return null;
47
+ const only = children[0];
48
+ if (only === null || typeof only !== 'object' || only.type !== 'text') return null;
49
+ return typeof only.value === 'string' ? only.value.trim() : null;
50
+ }
51
+
52
+ /** The query engine's boolean view of a value (an `#if` verdict). */
53
+ function truthy(value) {
54
+ if (value === undefined || value === null || value === false) return false;
55
+ if (value === '' || value === 0) return false;
56
+ if (typeof value === 'number' && Number.isNaN(value)) return false;
57
+ if (Array.isArray(value)) return value.length > 0;
58
+ return true;
59
+ }
60
+
61
+ /** An interpolated value → the text that replaces its `{…}` span. */
62
+ function stringify(value) {
63
+ if (value === undefined || value === null) return '';
64
+ if (typeof value === 'string') return value;
65
+ if (typeof value === 'object') {
66
+ try { return JSON.stringify(value); } catch { return String(value); }
67
+ }
68
+ return String(value);
69
+ }
70
+
71
+ /** Node types whose `value` is literal — never interpolated. */
72
+ const LITERAL = new Set(['code', 'inlineCode', 'html', 'math', 'inlineMath']);
73
+
74
+ /**
75
+ * Build the mdx transformer around an injected expression compiler.
76
+ * @param {{ compileQuery: (expr: string) => (data: any, externals?: any) => any }} options
77
+ * `compileQuery` compiles one query expression (e.g. `compileJsonQuery`
78
+ * from `@jarenjs/json`); compiled expressions are cached per source text
79
+ * @returns {{ transform: (doc: any, data: any) => any }}
80
+ * `transform(doc, data)` — a parsed markdown doc + a data document in,
81
+ * a NEW doc (directives resolved, text interpolated) out; never throws
82
+ * on a bad expression — the error message renders in place, honestly
83
+ */
84
+ export function createMdx(options) {
85
+ const compileQuery = options?.compileQuery;
86
+ if (typeof compileQuery !== 'function') {
87
+ throw new TypeError('createMdx needs options.compileQuery — inject a query compiler (compileJsonQuery from @jarenjs/json)');
88
+ }
89
+ /** @type {Map<string, any>} compiled-expression cache, keyed by source */
90
+ const cache = new Map();
91
+ const compile = (expr) => {
92
+ let compiled = cache.get(expr);
93
+ if (compiled === undefined) {
94
+ compiled = compileQuery(expr);
95
+ cache.set(expr, compiled);
96
+ }
97
+ return compiled;
98
+ };
99
+
100
+ /** Evaluate one expression; `{ value }` or `{ error }` — never a throw. */
101
+ const evaluate = (expr, data, externals) => {
102
+ try { return { value: compile(expr)(data, externals) }; }
103
+ catch (err) { return { error: String(/** @type {any} */ (err)?.message ?? err) }; }
104
+ };
105
+
106
+ /** One text value with its `{$…}` spans interpolated. */
107
+ const interpolateText = (text, data, externals) =>
108
+ text.replace(INLINE, (span, expr) => {
109
+ const out = evaluate(expr, data, externals);
110
+ return out.error !== undefined ? `⟨mdx: ${out.error}⟩` : stringify(out.value);
111
+ });
112
+
113
+ /** One inline/leaf node, interpolated (containers recurse). */
114
+ const interpolateNode = (node, data, externals) => {
115
+ if (node === null || typeof node !== 'object') return node;
116
+ if (LITERAL.has(node.type)) return node;
117
+ if (node.type === 'text' && typeof node.value === 'string') {
118
+ const value = interpolateText(node.value, data, externals);
119
+ return value === node.value ? node : { ...node, value };
120
+ }
121
+ if (Array.isArray(node.children)) {
122
+ return { ...node, children: transformBlocks(node.children, data, externals) };
123
+ }
124
+ return node;
125
+ };
126
+
127
+ /** Find the index of the section's matching close directive. */
128
+ const findClose = (nodes, from, kind) => {
129
+ let depth = 0;
130
+ for (let i = from; i < nodes.length; i++) {
131
+ const text = directiveText(nodes[i]);
132
+ if (text === null) continue;
133
+ if (OPEN_ANY.test(text)) { depth += 1; continue; }
134
+ const close = CLOSE.exec(text);
135
+ if (close === null) continue;
136
+ if (depth === 0) return close[1] === kind ? i : -1;
137
+ depth -= 1;
138
+ }
139
+ return -1;
140
+ };
141
+
142
+ /** A block list with its directives resolved and its text interpolated. */
143
+ const transformBlocks = (nodes, data, externals) => {
144
+ /** @type {any[]} */
145
+ const out = [];
146
+ for (let i = 0; i < nodes.length; i++) {
147
+ const node = nodes[i];
148
+ const text = directiveText(node);
149
+ if (text !== null) {
150
+ const eachOpen = OPEN_EACH.exec(text);
151
+ if (eachOpen !== null) {
152
+ const end = findClose(nodes, i + 1, 'each');
153
+ if (end === -1) { out.push(errorBlock(`unclosed {#each} — missing {/each}`)); break; }
154
+ const body = nodes.slice(i + 1, end);
155
+ const result = evaluate(eachOpen[1], data, externals);
156
+ if (result.error !== undefined) out.push(errorBlock(`mdx: ${result.error}`));
157
+ else {
158
+ const items = Array.isArray(result.value)
159
+ ? result.value
160
+ : result.value === undefined || result.value === null ? [] : [result.value];
161
+ for (const item of items) {
162
+ out.push(...transformBlocks(body, data, { ...externals, [eachOpen[2]]: item }));
163
+ }
164
+ }
165
+ i = end;
166
+ continue;
167
+ }
168
+ const ifOpen = OPEN_IF.exec(text);
169
+ if (ifOpen !== null) {
170
+ const end = findClose(nodes, i + 1, 'if');
171
+ if (end === -1) { out.push(errorBlock(`unclosed {#if} — missing {/if}`)); break; }
172
+ const result = evaluate(ifOpen[1], data, externals);
173
+ if (result.error !== undefined) out.push(errorBlock(`mdx: ${result.error}`));
174
+ else if (truthy(result.value)) {
175
+ out.push(...transformBlocks(nodes.slice(i + 1, end), data, externals));
176
+ }
177
+ i = end;
178
+ continue;
179
+ }
180
+ if (CLOSE.test(text)) {
181
+ // a stray close directive: surface it rather than swallow it
182
+ out.push(errorBlock(`stray ${text} — no matching opener`));
183
+ continue;
184
+ }
185
+ }
186
+ out.push(interpolateNode(node, data, externals));
187
+ }
188
+ return out;
189
+ };
190
+
191
+ return {
192
+ /**
193
+ * The pure mdx pass: a parsed markdown doc + a data document in, a
194
+ * new doc out (the input doc is never mutated). The frontmatter's
195
+ * members bind as externals, exactly as they do for JSLT.
196
+ * @param {any} doc - a `parseMarkdown` / `compileMarkdown(...).doc` document
197
+ * @param {any} data - the data document `$` addresses
198
+ */
199
+ transform(doc, data) {
200
+ const externals = frontmatterExternals(doc?.frontmatter ?? null);
201
+ // The comment spelling first: it resolves to TEXT nodes, which the
202
+ // brace pass then leaves alone (an interpolated value is never
203
+ // re-read as a template, in either spelling — that is the whole
204
+ // safety property). One evaluator serves both; only the carrier
205
+ // differs.
206
+ const resolved = replaceDirectives(doc?.ast ?? [], { ns: 'mdx' }, (directive) => {
207
+ const out = evaluate(directive.key, data, externals);
208
+ const value = out.error !== undefined ? `⟨mdx: ${out.error}⟩` : stringify(out.value);
209
+ return [{ type: 'text', value }];
210
+ });
211
+ return { ...doc, ast: transformBlocks(resolved, data, externals) };
212
+ },
213
+ };
214
+ }
215
+
216
+ /** A visible error paragraph — a bad template renders its diagnosis. */
217
+ function errorBlock(message) {
218
+ return { type: 'paragraph', children: [{ type: 'text', value: `⟨${message}⟩` }] };
219
+ }