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