@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/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
|
+
}
|
package/src/compiler.js
ADDED
|
@@ -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
|
+
}
|