@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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The Markdown VISUAL COMPONENT — part two of the package.
|
|
4
|
+
*
|
|
5
|
+
* Everything below this line is presentation glue; the engine
|
|
6
|
+
* (`@jarenjs/md`) neither knows nor needs any of it. The component
|
|
7
|
+
* layer packages the engine for hosts that render:
|
|
8
|
+
*
|
|
9
|
+
* - `createMdComponent()` — a batteries-included bundle: a memoized
|
|
10
|
+
* `view()` projection for `@jarenjs/app` viewModels (reference-
|
|
11
|
+
* stable, so unchanged sources patch in O(1)), `effects` entries
|
|
12
|
+
* for the app effect registry (`md-load`, `md-parse`), and a
|
|
13
|
+
* `hydrate()` pass for app-managed DOM;
|
|
14
|
+
* - `styles/md.css` — the component's stylesheet (`.md` content
|
|
15
|
+
* rhythm, `tok-*` token colors, mermaid placeholder), light/dark.
|
|
16
|
+
*
|
|
17
|
+
* The boundary is deliberate: the engine stays a headless data
|
|
18
|
+
* toolchain (text ↔ AST ↔ vnode values), the component owns defaults,
|
|
19
|
+
* memoization policy, CSS and app-registry shapes. See
|
|
20
|
+
* ARCHITECTURE.md §"Engine and component".
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createProjectionMemo } from '@jarenjs/view/helpers';
|
|
24
|
+
import { buildPluginTables } from '../parser.js';
|
|
25
|
+
import { compileMarkdown } from '../compiler.js';
|
|
26
|
+
import { loadMarkdown } from '../loader.js';
|
|
27
|
+
import { walkAst } from '../ast.js';
|
|
28
|
+
import { hashContent } from '../utils.js';
|
|
29
|
+
import { highlightPlugin } from '../plugins/highlight.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {import('../ast.js').MdDocument} MdDocument
|
|
33
|
+
* @typedef {import('../compiler.js').CompiledMd} CompiledMd
|
|
34
|
+
* @typedef {import('../compiler.js').MdCompileOptions} MdCompileOptions
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {MdCompileOptions & {
|
|
38
|
+
* base?: string | URL,
|
|
39
|
+
* fetch?: typeof globalThis.fetch,
|
|
40
|
+
* cache?: any,
|
|
41
|
+
* memoLimit?: number,
|
|
42
|
+
* onHydrateError?: (err: any) => void,
|
|
43
|
+
* }} MdComponentOptions
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* The component bundle.
|
|
47
|
+
* @typedef {object} MdComponent
|
|
48
|
+
* @property {any[]} plugins the compiled-in plugin set
|
|
49
|
+
* @property {(sourceOrDoc: any) => any} view memoized vnode projection
|
|
50
|
+
* @property {(source: string) => CompiledMd} compile memoized compile
|
|
51
|
+
* @property {Record<string, (props: any, dispatch: any) => any>} effects
|
|
52
|
+
* `md-load` and `md-parse` for `createApp({ effects })`
|
|
53
|
+
* @property {(container: any) => void} hydrate run plugin hydrate hooks
|
|
54
|
+
* over already-mounted DOM (no-op without hydratable plugins)
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create the Markdown component: one object that plugs the engine
|
|
59
|
+
* into an `@jarenjs/app` document (or any view-owning host).
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const md = createMdComponent();
|
|
63
|
+
* createApp(appDoc, {
|
|
64
|
+
* effects: { ...md.effects },
|
|
65
|
+
* viewModel: (state) => ({ ...state, article: md.view(state.articleSource) }),
|
|
66
|
+
* });
|
|
67
|
+
* // an action loads a document:
|
|
68
|
+
* // { "effects": [{ "run": "md-load", "with": { "url": "$.url", "done": "article/loaded" } }] }
|
|
69
|
+
*
|
|
70
|
+
* @param {MdComponentOptions} [options]
|
|
71
|
+
* @returns {MdComponent}
|
|
72
|
+
*/
|
|
73
|
+
export function createMdComponent(options = {}) {
|
|
74
|
+
const plugins = options.plugins ?? DEFAULT_PLUGINS;
|
|
75
|
+
const compileOptions = { ...options, plugins };
|
|
76
|
+
const memoLimit = options.memoLimit ?? 32;
|
|
77
|
+
const tables = buildPluginTables(plugins);
|
|
78
|
+
|
|
79
|
+
/** Content-hash → AST node, for hydratable plugin nodes. */
|
|
80
|
+
/** @type {Map<string, any>} */
|
|
81
|
+
const hydratable = new Map();
|
|
82
|
+
/** @type {WeakMap<any, string>} */
|
|
83
|
+
const hydrated = new WeakMap();
|
|
84
|
+
const onHydrateError = options.onHydrateError
|
|
85
|
+
// eslint-disable-next-line no-console -- the documented default sink
|
|
86
|
+
?? ((err) => console.error('md hydrate:', err));
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Remember hydratable nodes of a document by content hash.
|
|
90
|
+
* @param {MdDocument | any} doc
|
|
91
|
+
*/
|
|
92
|
+
const indexHydratable = (doc) => {
|
|
93
|
+
if (tables.hydrates.size === 0) return;
|
|
94
|
+
walkAst(doc.ast ?? doc, (node) => {
|
|
95
|
+
if (tables.hydrates.has(node.type) && typeof node.value === 'string') {
|
|
96
|
+
hydratable.set(hashContent(node.value), node);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const { compile, view } = createProjectionMemo({
|
|
102
|
+
memoLimit,
|
|
103
|
+
compile: (source) => {
|
|
104
|
+
const compiled = compileMarkdown(source, compileOptions);
|
|
105
|
+
indexHydratable(compiled.doc);
|
|
106
|
+
return compiled;
|
|
107
|
+
},
|
|
108
|
+
toVnode: (compiled) => compiled.toVnode(),
|
|
109
|
+
docToVnode: (doc) => {
|
|
110
|
+
const vnode = compileMarkdown(doc, compileOptions).toVnode();
|
|
111
|
+
indexHydratable(doc);
|
|
112
|
+
return vnode;
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** @type {MdComponent} */
|
|
117
|
+
const component = {
|
|
118
|
+
plugins,
|
|
119
|
+
|
|
120
|
+
compile,
|
|
121
|
+
|
|
122
|
+
view,
|
|
123
|
+
|
|
124
|
+
effects: {
|
|
125
|
+
/**
|
|
126
|
+
* Load a URL and dispatch the plain MdDocument:
|
|
127
|
+
* `{ run: 'md-load', with: { url, done, error? } }`.
|
|
128
|
+
*/
|
|
129
|
+
'md-load': (props, dispatch) =>
|
|
130
|
+
loadMarkdown(props.url, {
|
|
131
|
+
...compileOptions,
|
|
132
|
+
base: options.base,
|
|
133
|
+
fetch: options.fetch,
|
|
134
|
+
cache: options.cache,
|
|
135
|
+
}).then(
|
|
136
|
+
(compiled) => {
|
|
137
|
+
indexHydratable(compiled.doc);
|
|
138
|
+
dispatch(props.done, compiled.doc);
|
|
139
|
+
},
|
|
140
|
+
(err) => {
|
|
141
|
+
if (props.error !== undefined) {
|
|
142
|
+
dispatch(props.error, { url: String(props.url), message: String(err?.message ?? err) });
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
),
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse a source string and dispatch the plain MdDocument:
|
|
152
|
+
* `{ run: 'md-parse', with: { source, done } }`.
|
|
153
|
+
*/
|
|
154
|
+
'md-parse': (props, dispatch) => {
|
|
155
|
+
dispatch(props.done, compile(String(props.source ?? '')).doc);
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
hydrate(container) {
|
|
160
|
+
if (tables.hydrates.size === 0) return;
|
|
161
|
+
const marked = container.querySelectorAll('[data-md-hydrate]');
|
|
162
|
+
for (const el of marked) {
|
|
163
|
+
const name = el.getAttribute('data-md-hydrate');
|
|
164
|
+
const hash = el.getAttribute('data-md-hash') ?? '';
|
|
165
|
+
if (hydrated.get(el) === hash) continue;
|
|
166
|
+
const node = hydratable.get(hash);
|
|
167
|
+
if (node === undefined) continue;
|
|
168
|
+
const plugin = tables.hydrates.get(node.type);
|
|
169
|
+
if (plugin === undefined || plugin.name !== name) continue;
|
|
170
|
+
hydrated.set(el, hash);
|
|
171
|
+
try {
|
|
172
|
+
const result = plugin.hydrate(el, node, { options, hash: hashContent });
|
|
173
|
+
if (result !== undefined && result !== null && typeof result.catch === 'function') {
|
|
174
|
+
result.catch(onHydrateError);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
onHydrateError(err);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
return component;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The component's default plugin set: syntax highlighting on. A single
|
|
188
|
+
* shared array so `buildPluginTables` (and every memo hanging off it)
|
|
189
|
+
* compiles exactly once per process.
|
|
190
|
+
*/
|
|
191
|
+
export const DEFAULT_PLUGINS = [highlightPlugin()];
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Directives: comment-carried data that a machine derives and a
|
|
4
|
+
* human reads.
|
|
5
|
+
*
|
|
6
|
+
* ```markdown
|
|
7
|
+
* Jaren is <!--bm:jsonpath.ctsRatio-->23.1<!--/bm-->x faster on the CTS mean.
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* Every markdown renderer on earth drops HTML comments, so GitHub, an
|
|
11
|
+
* editor preview and npm all show `Jaren is 23.1x faster` — plain,
|
|
12
|
+
* correct, static text with no runtime and no template syntax leaking
|
|
13
|
+
* into the prose. A directive-aware consumer reads the marker instead
|
|
14
|
+
* and can re-derive the value; `bake` writes the fresh value back into
|
|
15
|
+
* the source, so a re-derivation is a reviewable diff rather than a
|
|
16
|
+
* silent drift.
|
|
17
|
+
*
|
|
18
|
+
* That is the gap this closes for mdx, whose `{$.path}` spelling renders
|
|
19
|
+
* as literal gibberish anywhere the transform has not run — usable only
|
|
20
|
+
* in documents nobody reads raw.
|
|
21
|
+
*
|
|
22
|
+
* **The layer never interprets the payload.** `bm` puts a fact key
|
|
23
|
+
* there, `mdx` puts a query expression; the vocabulary belongs to the
|
|
24
|
+
* consumer, and this module owns exactly one thing — the marker grammar
|
|
25
|
+
* and the pairing — so two consumers cannot disagree about what a
|
|
26
|
+
* directive is.
|
|
27
|
+
*
|
|
28
|
+
* There are two ways in, because there are two questions:
|
|
29
|
+
*
|
|
30
|
+
* - `scanDirectives(doc)` / `replaceDirectives(doc, …)` work on the
|
|
31
|
+
* AST, which is what a *rendering* consumer has;
|
|
32
|
+
* - `scanSourceDirectives(text)` works on the source, which is what a
|
|
33
|
+
* *rewriting* consumer needs. The AST carries no source offsets (it
|
|
34
|
+
* is plain JSON built for structural sharing, MD-FORMAT §1.1/§6), and
|
|
35
|
+
* giving it any would change every node's shape and therefore every
|
|
36
|
+
* content-hash key — so `bake` splices bytes instead, and both
|
|
37
|
+
* scanners read the same grammar from the same function.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {import('./ast.js').MdNode} MdNode
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* One paired directive found in an AST.
|
|
45
|
+
* @typedef {{ ns: string, key: string, scope: 'block'|'inline',
|
|
46
|
+
* path: number[], open: number, close: number, nodes: MdNode[] }} AstDirective
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* One paired directive found in source text. `start`/`end` span the
|
|
50
|
+
* whole thing, markers included; `bodyStart`/`bodyEnd` span what a
|
|
51
|
+
* resolver replaces.
|
|
52
|
+
* @typedef {{ ns: string, key: string, scope: 'block'|'inline',
|
|
53
|
+
* start: number, end: number, bodyStart: number, bodyEnd: number,
|
|
54
|
+
* body: string }} SourceDirective
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** `<!--ns:payload-->` or `<!--/ns-->`, and nothing else. */
|
|
58
|
+
const RE_OPEN = /^<!--([a-z][a-z0-9-]*):((?:(?!-->)[\s\S])*)-->$/;
|
|
59
|
+
const RE_CLOSE = /^<!--\/([a-z][a-z0-9-]*)-->$/;
|
|
60
|
+
/** Every comment in a source text, for the byte-level scanner. */
|
|
61
|
+
const RE_COMMENT = /<!--[\s\S]*?-->/g;
|
|
62
|
+
/** A fenced-code opener/closer, so the source scanner can skip fences. */
|
|
63
|
+
const RE_FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Classify one comment. The single source of truth for what a directive
|
|
67
|
+
* marker IS — both scanners and every consumer go through here.
|
|
68
|
+
* @param {string} text the comment, `<!--` and `-->` included
|
|
69
|
+
* @returns {{ ns: string, key: string, closing: boolean } | null}
|
|
70
|
+
*/
|
|
71
|
+
export function parseMarker(text) {
|
|
72
|
+
const close = RE_CLOSE.exec(text);
|
|
73
|
+
if (close !== null) return { ns: close[1], key: '', closing: true };
|
|
74
|
+
const open = RE_OPEN.exec(text);
|
|
75
|
+
// a closer is `<!--/ns-->`, so an opener whose payload starts with `/`
|
|
76
|
+
// would be ambiguous; the closer pattern already claimed those
|
|
77
|
+
if (open === null) return null;
|
|
78
|
+
return { ns: open[1], key: open[2].trim(), closing: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Pair a flat run of markers, in document order, into directives.
|
|
83
|
+
*
|
|
84
|
+
* Unpaired markers are REPORTED, never dropped: a stale figure hiding
|
|
85
|
+
* behind a marker nobody matched is the exact failure this whole layer
|
|
86
|
+
* exists to prevent. Same-`ns` nesting is rejected for the same reason —
|
|
87
|
+
* a directive whose body contains another of its own kind has no single
|
|
88
|
+
* answer to "what does the resolver replace?".
|
|
89
|
+
*
|
|
90
|
+
* @param {{ ns: string, key: string, closing: boolean, at: number }[]} markers
|
|
91
|
+
* @param {string|null} ns only this namespace, or null for all
|
|
92
|
+
* @returns {{ pairs: { ns: string, key: string, open: number, close: number }[],
|
|
93
|
+
* diagnostics: string[] }}
|
|
94
|
+
*/
|
|
95
|
+
function pairMarkers(markers, ns) {
|
|
96
|
+
const pairs = [];
|
|
97
|
+
/** @type {string[]} */
|
|
98
|
+
const diagnostics = [];
|
|
99
|
+
/** @type {{ ns: string, key: string, at: number } | null} */
|
|
100
|
+
let open = null;
|
|
101
|
+
for (const marker of markers) {
|
|
102
|
+
if (ns !== null && marker.ns !== ns) continue;
|
|
103
|
+
if (marker.closing) {
|
|
104
|
+
if (open === null) {
|
|
105
|
+
diagnostics.push(`stray <!--/${marker.ns}--> with no opener`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
pairs.push({ ns: open.ns, key: open.key, open: open.at, close: marker.at });
|
|
109
|
+
open = null;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (open !== null) {
|
|
113
|
+
diagnostics.push(`<!--${marker.ns}:${marker.key}--> opens inside <!--${open.ns}:${open.key}-->`
|
|
114
|
+
+ ' — directives of one namespace do not nest');
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
open = { ns: marker.ns, key: marker.key, at: marker.at };
|
|
118
|
+
}
|
|
119
|
+
if (open !== null) {
|
|
120
|
+
diagnostics.push(`<!--${open.ns}:${open.key}--> is never closed`);
|
|
121
|
+
}
|
|
122
|
+
return { pairs, diagnostics };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Find every directive in a parsed document.
|
|
127
|
+
*
|
|
128
|
+
* A block directive's markers are `html` block nodes with body blocks
|
|
129
|
+
* between them; an inline directive's are `html` inline nodes inside one
|
|
130
|
+
* paragraph. Markers are paired within ONE container — an opener in a
|
|
131
|
+
* blockquote and a closer outside it are two unpaired markers, not one
|
|
132
|
+
* directive spanning a boundary that does not exist in the tree.
|
|
133
|
+
*
|
|
134
|
+
* @param {any} docOrAst an MdDocument, a CompiledMd or an AST array
|
|
135
|
+
* @param {{ ns?: string }} [options] restrict to one namespace
|
|
136
|
+
* @returns {{ directives: AstDirective[], diagnostics: string[] }}
|
|
137
|
+
*/
|
|
138
|
+
export function scanDirectives(docOrAst, options = {}) {
|
|
139
|
+
const ast = Array.isArray(docOrAst) ? docOrAst : (docOrAst?.ast ?? []);
|
|
140
|
+
const ns = typeof options.ns === 'string' ? options.ns : null;
|
|
141
|
+
/** @type {AstDirective[]} */
|
|
142
|
+
const directives = [];
|
|
143
|
+
/** @type {string[]} */
|
|
144
|
+
const diagnostics = [];
|
|
145
|
+
scanLevel(ast, [], 'block', ns, directives, diagnostics);
|
|
146
|
+
// Document order. The walk pairs a container's own markers only after
|
|
147
|
+
// recursing into its children, so without this an inline directive
|
|
148
|
+
// inside a later paragraph would come back before the block directive
|
|
149
|
+
// that precedes it — and `bake` matches this list against the source
|
|
150
|
+
// scan position by position.
|
|
151
|
+
directives.sort((a, b) => comparePosition(a.path.concat(a.open), b.path.concat(b.open)));
|
|
152
|
+
return { directives, diagnostics };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Lexicographic comparison of two index paths. */
|
|
156
|
+
function comparePosition(a, b) {
|
|
157
|
+
for (let i = 0; i < a.length && i < b.length; i++) {
|
|
158
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
159
|
+
}
|
|
160
|
+
return a.length - b.length;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Node types whose `children` are INLINE content — the boundary between
|
|
165
|
+
* the two scopes. Everything else that carries children is a block
|
|
166
|
+
* container.
|
|
167
|
+
*/
|
|
168
|
+
const INLINE_CHILDREN = new Set(['paragraph', 'heading', 'tableCell']);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Pair the markers of ONE children array, then recurse.
|
|
172
|
+
*
|
|
173
|
+
* Markers pair within the array they share, and only there: an opener
|
|
174
|
+
* inside `**bold**` and a closer outside it are two unpaired markers,
|
|
175
|
+
* not one directive spanning a boundary the tree does not have. That is
|
|
176
|
+
* also why this is one function and not one per scope — the markers of
|
|
177
|
+
* a bolded span sit in the `strong` node's children, and a walker that
|
|
178
|
+
* only looked at a paragraph's DIRECT children missed every directive
|
|
179
|
+
* written inside emphasis.
|
|
180
|
+
*
|
|
181
|
+
* @param {MdNode[]} nodes @param {number[]} path
|
|
182
|
+
* @param {'block'|'inline'} scope @param {string|null} ns
|
|
183
|
+
* @param {AstDirective[]} directives @param {string[]} diagnostics
|
|
184
|
+
*/
|
|
185
|
+
function scanLevel(nodes, path, scope, ns, directives, diagnostics) {
|
|
186
|
+
const markers = [];
|
|
187
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
188
|
+
const node = nodes[i];
|
|
189
|
+
if (node.type === 'html') {
|
|
190
|
+
const marker = parseMarker(node.value);
|
|
191
|
+
if (marker !== null) markers.push({ ...marker, at: i });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (!Array.isArray(node.children)) continue;
|
|
195
|
+
const inner = scope === 'inline' || INLINE_CHILDREN.has(node.type) ? 'inline' : 'block';
|
|
196
|
+
scanLevel(node.children, path.concat(i), inner, ns, directives, diagnostics);
|
|
197
|
+
}
|
|
198
|
+
if (markers.length === 0) return;
|
|
199
|
+
const { pairs, diagnostics: found } = pairMarkers(markers, ns);
|
|
200
|
+
for (const pair of pairs) {
|
|
201
|
+
directives.push({
|
|
202
|
+
ns: pair.ns,
|
|
203
|
+
key: pair.key,
|
|
204
|
+
scope,
|
|
205
|
+
path,
|
|
206
|
+
open: pair.open,
|
|
207
|
+
close: pair.close,
|
|
208
|
+
nodes: nodes.slice(pair.open + 1, pair.close),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
for (const message of found) diagnostics.push(message);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Replace every directive body, returning a NEW document.
|
|
216
|
+
*
|
|
217
|
+
* The transform is pure and preserves reference equality for everything
|
|
218
|
+
* it does not touch, so the vnode emitter's per-node memo and the view
|
|
219
|
+
* patcher's `===` fast path are unaffected — the same contract the mdx
|
|
220
|
+
* pass keeps (MD-FORMAT §6).
|
|
221
|
+
*
|
|
222
|
+
* `replace` returns the nodes to put between the markers. Returning
|
|
223
|
+
* `undefined` leaves the directive alone.
|
|
224
|
+
*
|
|
225
|
+
* @param {any} docOrAst
|
|
226
|
+
* @param {{ ns?: string }} options
|
|
227
|
+
* @param {(directive: AstDirective) => (MdNode[]|undefined)} replace
|
|
228
|
+
* @returns {any} the same shape that came in (document or array)
|
|
229
|
+
*/
|
|
230
|
+
export function replaceDirectives(docOrAst, options, replace) {
|
|
231
|
+
const isArray = Array.isArray(docOrAst);
|
|
232
|
+
const ast = isArray ? docOrAst : (docOrAst?.ast ?? []);
|
|
233
|
+
const { directives } = scanDirectives(ast, options);
|
|
234
|
+
if (directives.length === 0) return docOrAst;
|
|
235
|
+
// deepest paths first, so an inner replacement's indices are still
|
|
236
|
+
// valid when its container is rebuilt around it
|
|
237
|
+
const byPath = new Map();
|
|
238
|
+
for (const directive of directives) {
|
|
239
|
+
const key = directive.path.join('.');
|
|
240
|
+
const bucket = byPath.get(key);
|
|
241
|
+
if (bucket === undefined) byPath.set(key, [directive]);
|
|
242
|
+
else bucket.push(directive);
|
|
243
|
+
}
|
|
244
|
+
const next = rewriteContainer(ast, [], byPath, replace);
|
|
245
|
+
if (next === ast) return docOrAst;
|
|
246
|
+
return isArray ? next : { ...docOrAst, ast: next };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @param {MdNode[]} nodes @param {number[]} path
|
|
251
|
+
* @param {Map<string, AstDirective[]>} byPath
|
|
252
|
+
* @param {(directive: AstDirective) => (MdNode[]|undefined)} replace
|
|
253
|
+
* @returns {MdNode[]}
|
|
254
|
+
*/
|
|
255
|
+
function rewriteContainer(nodes, path, byPath, replace) {
|
|
256
|
+
/** @type {MdNode[] | null} */
|
|
257
|
+
let out = null;
|
|
258
|
+
// children first: a rewritten child must land in the array this level
|
|
259
|
+
// then splices, not in the one it replaced
|
|
260
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
261
|
+
const node = nodes[i];
|
|
262
|
+
if (!Array.isArray(node.children)) continue;
|
|
263
|
+
const childPath = path.concat(i);
|
|
264
|
+
const rewritten = node.type === 'paragraph'
|
|
265
|
+
? spliceLevel(node.children, childPath, byPath, replace)
|
|
266
|
+
: rewriteContainer(node.children, childPath, byPath, replace);
|
|
267
|
+
if (rewritten === node.children) continue;
|
|
268
|
+
if (out === null) out = nodes.slice();
|
|
269
|
+
out[i] = { ...node, children: rewritten };
|
|
270
|
+
}
|
|
271
|
+
return spliceLevel(out ?? nodes, path, byPath, replace, out !== null);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Apply this level's own directives, right to left so earlier indices
|
|
276
|
+
* stay valid.
|
|
277
|
+
* @param {MdNode[]} nodes @param {number[]} path
|
|
278
|
+
* @param {Map<string, AstDirective[]>} byPath
|
|
279
|
+
* @param {(directive: AstDirective) => (MdNode[]|undefined)} replace
|
|
280
|
+
* @param {boolean} [owned] `nodes` is already a fresh array
|
|
281
|
+
* @returns {MdNode[]}
|
|
282
|
+
*/
|
|
283
|
+
function spliceLevel(nodes, path, byPath, replace, owned = false) {
|
|
284
|
+
const here = byPath.get(path.join('.'));
|
|
285
|
+
if (here === undefined) return nodes;
|
|
286
|
+
let out = owned ? nodes : null;
|
|
287
|
+
for (let d = here.length - 1; d >= 0; d--) {
|
|
288
|
+
const directive = here[d];
|
|
289
|
+
const body = replace(directive);
|
|
290
|
+
if (body === undefined) continue;
|
|
291
|
+
if (out === null) out = nodes.slice();
|
|
292
|
+
out.splice(directive.open + 1, directive.close - directive.open - 1, ...body);
|
|
293
|
+
}
|
|
294
|
+
return out ?? nodes;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Find every directive in SOURCE TEXT, with the offsets a rewriter
|
|
299
|
+
* needs.
|
|
300
|
+
*
|
|
301
|
+
* Comments inside fenced code are skipped: a fence showing a directive
|
|
302
|
+
* as an EXAMPLE is documentation about the layer, not an instance of it,
|
|
303
|
+
* and rewriting one would corrupt the very docs that explain it.
|
|
304
|
+
* (Indented code is not skipped — see the package README's note; the
|
|
305
|
+
* cross-check in `bake` is what catches the difference.)
|
|
306
|
+
*
|
|
307
|
+
* @param {string} source
|
|
308
|
+
* @param {{ ns?: string }} [options]
|
|
309
|
+
* @returns {{ directives: SourceDirective[], diagnostics: string[] }}
|
|
310
|
+
*/
|
|
311
|
+
export function scanSourceDirectives(source, options = {}) {
|
|
312
|
+
const ns = typeof options.ns === 'string' ? options.ns : null;
|
|
313
|
+
const fenced = fencedRanges(source);
|
|
314
|
+
const markers = [];
|
|
315
|
+
RE_COMMENT.lastIndex = 0;
|
|
316
|
+
let match;
|
|
317
|
+
while ((match = RE_COMMENT.exec(source)) !== null) {
|
|
318
|
+
if (inRanges(fenced, match.index)) continue;
|
|
319
|
+
const marker = parseMarker(match[0]);
|
|
320
|
+
if (marker === null) continue;
|
|
321
|
+
markers.push({ ...marker, at: match.index, end: match.index + match[0].length });
|
|
322
|
+
}
|
|
323
|
+
const { pairs, diagnostics } = pairMarkers(markers, ns);
|
|
324
|
+
/** @type {SourceDirective[]} */
|
|
325
|
+
const directives = pairs.map((pair) => {
|
|
326
|
+
const open = markers.find((m) => m.at === pair.open);
|
|
327
|
+
const close = markers.find((m) => m.at === pair.close);
|
|
328
|
+
const bodyStart = /** @type {any} */ (open).end;
|
|
329
|
+
const bodyEnd = /** @type {any} */ (close).at;
|
|
330
|
+
const body = source.slice(bodyStart, bodyEnd);
|
|
331
|
+
return {
|
|
332
|
+
ns: pair.ns,
|
|
333
|
+
key: pair.key,
|
|
334
|
+
scope: body.indexOf('\n') === -1 ? 'inline' : 'block',
|
|
335
|
+
start: pair.open,
|
|
336
|
+
end: /** @type {any} */ (close).end,
|
|
337
|
+
bodyStart,
|
|
338
|
+
bodyEnd,
|
|
339
|
+
body,
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
return { directives, diagnostics };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The `[start, end)` ranges of fenced code blocks in a source text. */
|
|
346
|
+
function fencedRanges(source) {
|
|
347
|
+
const ranges = [];
|
|
348
|
+
let offset = 0;
|
|
349
|
+
let open = null;
|
|
350
|
+
for (const line of source.split('\n')) {
|
|
351
|
+
const fence = RE_FENCE.exec(line);
|
|
352
|
+
if (fence !== null) {
|
|
353
|
+
if (open === null) open = { marker: fence[1][0], length: fence[1].length, at: offset };
|
|
354
|
+
else if (fence[1][0] === open.marker && fence[1].length >= open.length) {
|
|
355
|
+
ranges.push([open.at, offset + line.length]);
|
|
356
|
+
open = null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
offset += line.length + 1;
|
|
360
|
+
}
|
|
361
|
+
if (open !== null) ranges.push([open.at, source.length]);
|
|
362
|
+
return ranges;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** @param {number[][]} ranges @param {number} at */
|
|
366
|
+
function inRanges(ranges, at) {
|
|
367
|
+
for (const [start, end] of ranges) {
|
|
368
|
+
if (at >= start && at < end) return true;
|
|
369
|
+
}
|
|
370
|
+
return false;
|
|
371
|
+
}
|