@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/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
|
+
}
|