@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,947 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Frontmatter extraction: YAML subset, JSON and TOML → plain JSON.
4
+ *
5
+ * Frontmatter is detected at the very top of the source only:
6
+ *
7
+ * - `---` opens a YAML-subset block, closed by `---` or `...`
8
+ * - `---json` opens a JSON block, closed by `---`
9
+ * - `{` (as the first character) opens a JSON object closed by a
10
+ * line that is exactly `}`
11
+ * - `+++` opens a TOML block, closed by `+++`
12
+ *
13
+ * Whatever the syntax, the result normalizes to one plain JSON value on
14
+ * the document. The parsers are written from scratch and dependency-free;
15
+ * an external TOML parser (e.g. `parseToml` from `@jarenjs/josl`) can be
16
+ * injected through `options.toml` to replace the built-in TOML subset.
17
+ *
18
+ * The YAML subset (normative limits in docs/MD-FORMAT.md §3):
19
+ * scalars (null/booleans/numbers/strings), single- and double-quoted
20
+ * strings, block maps and sequences by indentation, flow arrays and
21
+ * maps (multi-line while brackets are open), literal `|` and folded `>`
22
+ * block scalars with `-` chomping, and `#` comments. No anchors, no
23
+ * aliases, no tags, no multi-document streams, no complex keys.
24
+ */
25
+
26
+ import { setObjectMember } from '@jarenjs/core/object';
27
+ import { countIndent, isBlankLine } from './utils.js';
28
+
29
+ /** Raised for malformed frontmatter inside a detected fence. */
30
+ export class MdFrontmatterError extends Error {
31
+ /**
32
+ * @param {string} message
33
+ * @param {number} line 0-based line index inside the frontmatter block
34
+ */
35
+ constructor(message, line) {
36
+ super(`md frontmatter: ${message} (line ${line + 1})`);
37
+ this.name = 'MdFrontmatterError';
38
+ this.line = line;
39
+ }
40
+ }
41
+
42
+ const RE_NUMBER = /^[+-]?(?:\d+|\d*\.\d+|\d+\.\d*)(?:[eE][+-]?\d+)?$/;
43
+ const RE_TOML_NUMBER = /^[+-]?(?:0x[0-9a-fA-F_]+|0o[0-7_]+|0b[01_]+|(?:\d[\d_]*)(?:\.[\d_]+)?(?:[eE][+-]?[\d_]+)?)$/;
44
+
45
+ /**
46
+ * Split frontmatter off the top of a Markdown source.
47
+ *
48
+ * @param {string} source
49
+ * @param {{ toml?: (text: string) => any }} [options]
50
+ * @returns {{ data: any, body: string, lang: 'yaml'|'json'|'toml'|null }}
51
+ */
52
+ export function parseFrontmatter(source, options = undefined) {
53
+ if (source.length === 0) return { data: null, body: source, lang: null };
54
+ const c0 = source.charCodeAt(0);
55
+ if (c0 === 0x2D /* - */) {
56
+ if (startsWithLine(source, '---json')) {
57
+ const block = sliceFenced(source, '---json'.length, '---');
58
+ if (block !== null) {
59
+ return { data: parseJsonBlock(block.text), body: block.body, lang: 'json' };
60
+ }
61
+ }
62
+ else if (startsWithLine(source, '---')) {
63
+ const block = sliceFenced(source, 3, '---', '...');
64
+ if (block !== null) {
65
+ return { data: parseYamlSubset(block.text), body: block.body, lang: 'yaml' };
66
+ }
67
+ }
68
+ }
69
+ else if (c0 === 0x2B /* + */ && startsWithLine(source, '+++')) {
70
+ const block = sliceFenced(source, 3, '+++');
71
+ if (block !== null) {
72
+ const toml = options !== undefined && typeof options.toml === 'function'
73
+ ? options.toml
74
+ : parseTomlSubset;
75
+ return { data: toml(block.text), body: block.body, lang: 'toml' };
76
+ }
77
+ }
78
+ else if (c0 === 0x7B /* { */) {
79
+ const block = sliceJsonObject(source);
80
+ if (block !== null) {
81
+ return { data: block.data, body: block.body, lang: 'json' };
82
+ }
83
+ }
84
+ return { data: null, body: source, lang: null };
85
+ }
86
+
87
+ /**
88
+ * Does the source start with `marker` as a complete first line?
89
+ * @param {string} source
90
+ * @param {string} marker
91
+ * @returns {boolean}
92
+ */
93
+ function startsWithLine(source, marker) {
94
+ if (!source.startsWith(marker)) return false;
95
+ const next = source.charCodeAt(marker.length);
96
+ return Number.isNaN(next) || next === 0x0A || next === 0x0D;
97
+ }
98
+
99
+ /**
100
+ * Slice the text between an opening marker (already matched at position
101
+ * 0, `openLength` chars) and the first closing marker line. Returns
102
+ * `null` when no closing line exists — the document has no frontmatter.
103
+ * @param {string} source
104
+ * @param {number} openLength
105
+ * @param {...string} closers
106
+ * @returns {{ text: string, body: string } | null}
107
+ */
108
+ function sliceFenced(source, openLength, ...closers) {
109
+ let pos = source.indexOf('\n', openLength);
110
+ if (pos === -1) return null;
111
+ const start = pos + 1;
112
+ while (pos !== -1) {
113
+ const lineStart = pos + 1;
114
+ let lineEnd = source.indexOf('\n', lineStart);
115
+ const hardEnd = lineEnd === -1 ? source.length : lineEnd;
116
+ const line = source.slice(lineStart, hardEnd).replace(/[ \t\r]+$/, '');
117
+ if (closers.includes(line)) {
118
+ return {
119
+ text: source.slice(start, lineStart),
120
+ body: lineEnd === -1 ? '' : source.slice(lineEnd + 1),
121
+ };
122
+ }
123
+ pos = lineEnd;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Parse the JSON frontmatter form that starts at `{` on line one and
130
+ * closes at the first line that is exactly `}`. Returns `null` when the
131
+ * shape does not hold (the `{` was just paragraph text).
132
+ * @param {string} source
133
+ * @returns {{ data: any, body: string } | null}
134
+ */
135
+ function sliceJsonObject(source) {
136
+ let pos = 0;
137
+ while (pos < source.length) {
138
+ let lineEnd = source.indexOf('\n', pos);
139
+ const hardEnd = lineEnd === -1 ? source.length : lineEnd;
140
+ const line = source.slice(pos, hardEnd).replace(/[ \t\r]+$/, '');
141
+ if (line === '}') {
142
+ try {
143
+ return {
144
+ data: JSON.parse(source.slice(0, hardEnd)),
145
+ body: lineEnd === -1 ? '' : source.slice(lineEnd + 1),
146
+ };
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ if (lineEnd === -1) break;
153
+ pos = lineEnd + 1;
154
+ }
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * Parse a `---json` block (JSON.parse with a located error).
160
+ * @param {string} text
161
+ * @returns {any}
162
+ */
163
+ function parseJsonBlock(text) {
164
+ try {
165
+ return JSON.parse(text);
166
+ }
167
+ catch (err) {
168
+ throw new MdFrontmatterError(
169
+ `invalid JSON: ${/** @type {Error} */ (err).message}`, 0);
170
+ }
171
+ }
172
+
173
+ // ------------------------------------------------------------------
174
+ // YAML subset
175
+ // ------------------------------------------------------------------
176
+
177
+ /**
178
+ * Parse the YAML subset into plain JSON.
179
+ * @param {string} text
180
+ * @returns {any}
181
+ */
182
+ export function parseYamlSubset(text) {
183
+ const lines = text.split('\n');
184
+ for (let i = 0; i < lines.length; i++) {
185
+ const line = lines[i];
186
+ if (line.endsWith('\r')) lines[i] = line.slice(0, -1);
187
+ }
188
+ const state = { lines, pos: 0 };
189
+ skipYamlVoid(state);
190
+ if (state.pos >= lines.length) return {};
191
+ const value = parseYamlNode(state, countIndent(lines[state.pos]));
192
+ skipYamlVoid(state);
193
+ if (state.pos < lines.length) {
194
+ throw new MdFrontmatterError('trailing content after the root value', state.pos);
195
+ }
196
+ return value;
197
+ }
198
+
199
+ /**
200
+ * @typedef {{ lines: string[], pos: number }} YamlState
201
+ */
202
+
203
+ /**
204
+ * Advance past blank and comment-only lines.
205
+ * @param {YamlState} state
206
+ */
207
+ function skipYamlVoid(state) {
208
+ while (state.pos < state.lines.length) {
209
+ const line = state.lines[state.pos];
210
+ if (!isBlankLine(line) && line.charCodeAt(countIndent(line)) !== 0x23 /* # */) {
211
+ return;
212
+ }
213
+ state.pos++;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Parse the block node whose first line sits at `indent`.
219
+ * @param {YamlState} state
220
+ * @param {number} indent
221
+ * @returns {any}
222
+ */
223
+ function parseYamlNode(state, indent) {
224
+ const line = state.lines[state.pos];
225
+ const content = line.slice(indent);
226
+ return isSeqDash(content)
227
+ ? parseYamlSeq(state, indent)
228
+ : parseYamlMap(state, indent);
229
+ }
230
+
231
+ /**
232
+ * Is this trimmed-left content a sequence entry (`- item` or a lone `-`)?
233
+ * @param {string} content
234
+ * @returns {boolean}
235
+ */
236
+ function isSeqDash(content) {
237
+ return content.charCodeAt(0) === 0x2D
238
+ && (content.length === 1 || content.charCodeAt(1) === 0x20);
239
+ }
240
+
241
+ /**
242
+ * Parse a block map at `indent`.
243
+ * @param {YamlState} state
244
+ * @param {number} indent
245
+ * @returns {Record<string, any>}
246
+ */
247
+ function parseYamlMap(state, indent) {
248
+ /** @type {Record<string, any>} */
249
+ const out = {};
250
+ while (state.pos < state.lines.length) {
251
+ skipYamlVoid(state);
252
+ if (state.pos >= state.lines.length) break;
253
+ const line = state.lines[state.pos];
254
+ const li = countIndent(line);
255
+ if (li < indent) break;
256
+ if (li > indent) {
257
+ throw new MdFrontmatterError('unexpected indentation', state.pos);
258
+ }
259
+ if (isSeqDash(line.slice(indent))) break;
260
+ const entry = splitYamlKey(line, indent, state.pos);
261
+ state.pos++;
262
+ setObjectMember(out, entry.key, parseYamlValue(state, entry.rest, indent));
263
+ }
264
+ return out;
265
+ }
266
+
267
+ /**
268
+ * Parse a block sequence at `indent`.
269
+ * @param {YamlState} state
270
+ * @param {number} indent
271
+ * @returns {any[]}
272
+ */
273
+ function parseYamlSeq(state, indent) {
274
+ /** @type {any[]} */
275
+ const out = [];
276
+ while (state.pos < state.lines.length) {
277
+ skipYamlVoid(state);
278
+ if (state.pos >= state.lines.length) break;
279
+ const line = state.lines[state.pos];
280
+ const li = countIndent(line);
281
+ if (li < indent) break;
282
+ const content = line.slice(indent);
283
+ if (li > indent || !isSeqDash(content)) break;
284
+ let restColumn = indent + 1;
285
+ while (restColumn < line.length && line.charCodeAt(restColumn) === 0x20) restColumn++;
286
+ const rest = line.slice(restColumn);
287
+ if (rest === '' || rest.charCodeAt(0) === 0x23 /* # */) {
288
+ // `-` alone: the item is the following deeper block (or null).
289
+ state.pos++;
290
+ out.push(parseYamlNested(state, indent, null));
291
+ }
292
+ else if (findKeyColon(rest) !== -1) {
293
+ // `- key: value`: an inline map item; re-enter the map parser at
294
+ // the rest's column by blanking the dash out of the current line.
295
+ state.lines[state.pos] = ' '.repeat(restColumn) + rest;
296
+ out.push(parseYamlMap(state, restColumn));
297
+ }
298
+ else {
299
+ state.pos++;
300
+ out.push(parseYamlFlowOrScalar(state, rest, state.pos - 1));
301
+ }
302
+ }
303
+ return out;
304
+ }
305
+
306
+ /**
307
+ * Parse the value of a map entry: inline scalar/flow, block scalar, or
308
+ * a nested block on the following lines.
309
+ * @param {YamlState} state
310
+ * @param {string} rest text after `key:` (left-trimmed)
311
+ * @param {number} indent the map's indent
312
+ * @returns {any}
313
+ */
314
+ function parseYamlValue(state, rest, indent) {
315
+ if (rest === '' || rest.charCodeAt(0) === 0x23 /* # */) {
316
+ return parseYamlNested(state, indent, null);
317
+ }
318
+ const c0 = rest.charCodeAt(0);
319
+ if (c0 === 0x7C /* | */ || c0 === 0x3E /* > */) {
320
+ const header = rest.split('#')[0].trim();
321
+ if (header === '|' || header === '|-' || header === '>' || header === '>-') {
322
+ return parseYamlBlockScalar(state, indent, header);
323
+ }
324
+ }
325
+ return parseYamlFlowOrScalar(state, rest, state.pos - 1);
326
+ }
327
+
328
+ /**
329
+ * Parse the nested block value after a key (or lone dash) at `indent`:
330
+ * a deeper block node, a sequence at the same indent, or `fallback`.
331
+ * @param {YamlState} state
332
+ * @param {number} indent
333
+ * @param {any} fallback
334
+ * @returns {any}
335
+ */
336
+ function parseYamlNested(state, indent, fallback) {
337
+ const mark = state.pos;
338
+ skipYamlVoid(state);
339
+ if (state.pos < state.lines.length) {
340
+ const line = state.lines[state.pos];
341
+ const li = countIndent(line);
342
+ if (li > indent) return parseYamlNode(state, li);
343
+ if (li === indent && isSeqDash(line.slice(li))) return parseYamlSeq(state, li);
344
+ }
345
+ state.pos = mark;
346
+ return fallback;
347
+ }
348
+
349
+ /**
350
+ * Parse a `|`/`>` block scalar. The chomp `-` drops the final newline.
351
+ * @param {YamlState} state
352
+ * @param {number} indent indent of the owning key
353
+ * @param {string} header `|`, `|-`, `>` or `>-`
354
+ * @returns {string}
355
+ */
356
+ function parseYamlBlockScalar(state, indent, header) {
357
+ /** @type {string[]} */
358
+ const raw = [];
359
+ let blockIndent = -1;
360
+ while (state.pos < state.lines.length) {
361
+ const line = state.lines[state.pos];
362
+ if (isBlankLine(line)) {
363
+ raw.push('');
364
+ state.pos++;
365
+ continue;
366
+ }
367
+ const li = countIndent(line);
368
+ if (li <= indent) break;
369
+ if (blockIndent === -1) blockIndent = li;
370
+ raw.push(line.slice(Math.min(li, blockIndent)));
371
+ state.pos++;
372
+ }
373
+ while (raw.length > 0 && raw[raw.length - 1] === '') raw.pop();
374
+ let text;
375
+ if (header.charCodeAt(0) === 0x7C /* | */) {
376
+ text = raw.join('\n');
377
+ }
378
+ else {
379
+ text = '';
380
+ for (let i = 0; i < raw.length; i++) {
381
+ if (i === 0) text = raw[0];
382
+ else if (raw[i] === '' || raw[i - 1] === '') text += '\n' + raw[i];
383
+ else text += ' ' + raw[i];
384
+ }
385
+ }
386
+ return header.length === 2 ? text : text + '\n';
387
+ }
388
+
389
+ /**
390
+ * Split a map line into its key and the value text after the colon.
391
+ * @param {string} line
392
+ * @param {number} indent
393
+ * @param {number} lineNo
394
+ * @returns {{ key: string, rest: string, restColumn: number }}
395
+ */
396
+ function splitYamlKey(line, indent, lineNo) {
397
+ const content = line.slice(indent);
398
+ const colon = findKeyColon(content);
399
+ if (colon === -1) {
400
+ throw new MdFrontmatterError(`expected 'key: value', got '${content.trim()}'`, lineNo);
401
+ }
402
+ let key = content.slice(0, colon).trim();
403
+ if (key.length > 1) {
404
+ const q = key.charCodeAt(0);
405
+ if ((q === 0x22 || q === 0x27) && key.charCodeAt(key.length - 1) === q) {
406
+ key = String(parseQuoted(key, 0, /** @type {'"'|"'"} */ (key[0])).value);
407
+ }
408
+ }
409
+ let restColumn = indent + colon + 1;
410
+ while (restColumn < line.length && line.charCodeAt(restColumn) === 0x20) restColumn++;
411
+ return { key, rest: line.slice(restColumn), restColumn };
412
+ }
413
+
414
+ /**
415
+ * Find the `:` that separates a key from its value: followed by a space
416
+ * or end of line, outside quotes and flow brackets. Returns -1 when the
417
+ * content is not a map entry.
418
+ * @param {string} content
419
+ * @returns {number}
420
+ */
421
+ function findKeyColon(content) {
422
+ let depth = 0;
423
+ let quote = 0;
424
+ for (let i = 0; i < content.length; i++) {
425
+ const c = content.charCodeAt(i);
426
+ if (quote !== 0) {
427
+ if (c === quote && !(quote === 0x22 && content.charCodeAt(i - 1) === 0x5C)) quote = 0;
428
+ continue;
429
+ }
430
+ if (c === 0x22 || c === 0x27) quote = c;
431
+ else if (c === 0x5B || c === 0x7B) depth++;
432
+ else if (c === 0x5D || c === 0x7D) depth--;
433
+ else if (c === 0x3A && depth === 0) {
434
+ const next = content.charCodeAt(i + 1);
435
+ if (Number.isNaN(next) || next === 0x20 || next === 0x09) {
436
+ return i === 0 ? -1 : i;
437
+ }
438
+ }
439
+ }
440
+ return -1;
441
+ }
442
+
443
+ /**
444
+ * Parse an inline value: flow collection (joining following lines while
445
+ * brackets stay open) or scalar.
446
+ * @param {YamlState} state
447
+ * @param {string} rest
448
+ * @param {number} lineNo
449
+ * @returns {any}
450
+ */
451
+ function parseYamlFlowOrScalar(state, rest, lineNo) {
452
+ const c0 = rest.charCodeAt(0);
453
+ if (c0 === 0x5B /* [ */ || c0 === 0x7B /* { */) {
454
+ let text = rest;
455
+ while (flowDepth(text) > 0 && state.pos < state.lines.length) {
456
+ text += ' ' + state.lines[state.pos].trim();
457
+ state.pos++;
458
+ }
459
+ if (flowDepth(text) !== 0) {
460
+ throw new MdFrontmatterError('unterminated flow collection', lineNo);
461
+ }
462
+ const flow = parseFlowValue(text, 0, lineNo);
463
+ return flow.value;
464
+ }
465
+ return parseYamlScalar(stripComment(rest));
466
+ }
467
+
468
+ /**
469
+ * Net bracket depth of a line, ignoring brackets inside quotes.
470
+ * @param {string} text
471
+ * @returns {number}
472
+ */
473
+ function flowDepth(text) {
474
+ let depth = 0;
475
+ let quote = 0;
476
+ for (let i = 0; i < text.length; i++) {
477
+ const c = text.charCodeAt(i);
478
+ if (quote !== 0) {
479
+ if (c === quote && !(quote === 0x22 && text.charCodeAt(i - 1) === 0x5C)) quote = 0;
480
+ }
481
+ else if (c === 0x22 || c === 0x27) quote = c;
482
+ else if (c === 0x5B || c === 0x7B) depth++;
483
+ else if (c === 0x5D || c === 0x7D) depth--;
484
+ }
485
+ return depth;
486
+ }
487
+
488
+ /**
489
+ * Strip a ` #comment` tail from a plain scalar (quote-aware).
490
+ * @param {string} text
491
+ * @returns {string}
492
+ */
493
+ function stripComment(text) {
494
+ let quote = 0;
495
+ for (let i = 0; i < text.length; i++) {
496
+ const c = text.charCodeAt(i);
497
+ if (quote !== 0) {
498
+ if (c === quote && !(quote === 0x22 && text.charCodeAt(i - 1) === 0x5C)) quote = 0;
499
+ }
500
+ else if (c === 0x22 || c === 0x27) quote = c;
501
+ else if (c === 0x23 && i > 0) {
502
+ const prev = text.charCodeAt(i - 1);
503
+ if (prev === 0x20 || prev === 0x09) return text.slice(0, i).trimEnd();
504
+ }
505
+ }
506
+ return text.trimEnd();
507
+ }
508
+
509
+ /**
510
+ * Parse a scalar: quoted string, null, boolean, number, or plain string.
511
+ * @param {string} text trimmed scalar text
512
+ * @returns {any}
513
+ */
514
+ function parseYamlScalar(text) {
515
+ if (text === '') return null;
516
+ const c0 = text.charCodeAt(0);
517
+ if (c0 === 0x22 || c0 === 0x27) {
518
+ return parseQuoted(text, 0, /** @type {'"'|"'"} */ (text[0])).value;
519
+ }
520
+ switch (text) {
521
+ case 'null': case 'Null': case 'NULL': case '~': return null;
522
+ case 'true': case 'True': case 'TRUE': return true;
523
+ case 'false': case 'False': case 'FALSE': return false;
524
+ default: break;
525
+ }
526
+ if (RE_NUMBER.test(text)) return Number(text);
527
+ return text;
528
+ }
529
+
530
+ /**
531
+ * Parse a quoted string starting at `pos`. Double quotes take JSON-style
532
+ * escapes; single quotes escape only `''` → `'`.
533
+ * @param {string} text
534
+ * @param {number} pos
535
+ * @param {'"'|"'"} quote
536
+ * @returns {{ value: string, end: number }}
537
+ */
538
+ function parseQuoted(text, pos, quote) {
539
+ let out = '';
540
+ let i = pos + 1;
541
+ while (i < text.length) {
542
+ const ch = text[i];
543
+ if (ch === quote) {
544
+ if (quote === "'" && text[i + 1] === "'") {
545
+ out += "'";
546
+ i += 2;
547
+ continue;
548
+ }
549
+ return { value: out, end: i + 1 };
550
+ }
551
+ if (quote === '"' && ch === '\\') {
552
+ const esc = text[i + 1];
553
+ switch (esc) {
554
+ case 'n': out += '\n'; break;
555
+ case 't': out += '\t'; break;
556
+ case 'r': out += '\r'; break;
557
+ case 'b': out += '\b'; break;
558
+ case 'f': out += '\f'; break;
559
+ case '0': out += '\0'; break;
560
+ case 'u':
561
+ out += String.fromCharCode(parseInt(text.slice(i + 2, i + 6), 16) || 0);
562
+ i += 4;
563
+ break;
564
+ default: out += esc ?? '';
565
+ }
566
+ i += 2;
567
+ continue;
568
+ }
569
+ out += ch;
570
+ i++;
571
+ }
572
+ return { value: out, end: i };
573
+ }
574
+
575
+ /**
576
+ * Parse a flow value (`[...]`, `{...}`, quoted or plain scalar) inside a
577
+ * single-line flow text.
578
+ * @param {string} text
579
+ * @param {number} pos
580
+ * @param {number} lineNo
581
+ * @returns {{ value: any, end: number }}
582
+ */
583
+ function parseFlowValue(text, pos, lineNo) {
584
+ while (pos < text.length && text.charCodeAt(pos) === 0x20) pos++;
585
+ const c = text.charCodeAt(pos);
586
+ if (c === 0x5B /* [ */) return parseFlowSeq(text, pos, lineNo);
587
+ if (c === 0x7B /* { */) return parseFlowMap(text, pos, lineNo);
588
+ if (c === 0x22 || c === 0x27) {
589
+ return parseQuoted(text, pos, /** @type {'"'|"'"} */ (text[pos]));
590
+ }
591
+ let end = pos;
592
+ let depth = 0;
593
+ while (end < text.length) {
594
+ const cc = text.charCodeAt(end);
595
+ if (depth === 0 && (cc === 0x2C || cc === 0x5D || cc === 0x7D || cc === 0x3A)) break;
596
+ if (cc === 0x5B || cc === 0x7B) depth++;
597
+ else if (cc === 0x5D || cc === 0x7D) depth--;
598
+ end++;
599
+ }
600
+ return { value: parseYamlScalar(text.slice(pos, end).trim()), end };
601
+ }
602
+
603
+ /**
604
+ * Parse a bracketed flow array `[a, b, ...]`; shared by the YAML flow
605
+ * sequence and the TOML flow array, which differ only in start offset,
606
+ * item parser and error message.
607
+ * @param {string} text
608
+ * @param {number} pos index of `[`
609
+ * @param {number} lineNo
610
+ * @param {(text: string, pos: number, lineNo: number) => { value: any, end: number }} parseItem
611
+ * @param {string} unterminated - error message for a missing `]`
612
+ * @returns {{ value: any[], end: number }}
613
+ */
614
+ function parseFlowArray(text, pos, lineNo, parseItem, unterminated) {
615
+ /** @type {any[]} */
616
+ const out = [];
617
+ let i = pos + 1;
618
+ for (;;) {
619
+ while (i < text.length && (text.charCodeAt(i) === 0x20 || text.charCodeAt(i) === 0x2C)) i++;
620
+ if (i >= text.length) throw new MdFrontmatterError(unterminated, lineNo);
621
+ if (text.charCodeAt(i) === 0x5D /* ] */) return { value: out, end: i + 1 };
622
+ const item = parseItem(text, i, lineNo);
623
+ out.push(item.value);
624
+ i = item.end;
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Parse a flow sequence `[a, b, ...]`.
630
+ * @param {string} text
631
+ * @param {number} pos index of `[`
632
+ * @param {number} lineNo
633
+ * @returns {{ value: any[], end: number }}
634
+ */
635
+ function parseFlowSeq(text, pos, lineNo) {
636
+ return parseFlowArray(text, pos, lineNo, parseFlowValue, 'unterminated flow sequence');
637
+ }
638
+
639
+ /**
640
+ * Parse a flow map `{a: 1, b: 2}`.
641
+ * @param {string} text
642
+ * @param {number} pos index of `{`
643
+ * @param {number} lineNo
644
+ * @returns {{ value: Record<string, any>, end: number }}
645
+ */
646
+ function parseFlowMap(text, pos, lineNo) {
647
+ /** @type {Record<string, any>} */
648
+ const out = {};
649
+ let i = pos + 1;
650
+ for (;;) {
651
+ while (i < text.length && (text.charCodeAt(i) === 0x20 || text.charCodeAt(i) === 0x2C)) i++;
652
+ if (i >= text.length) throw new MdFrontmatterError('unterminated flow map', lineNo);
653
+ if (text.charCodeAt(i) === 0x7D /* } */) return { value: out, end: i + 1 };
654
+ const key = parseFlowValue(text, i, lineNo);
655
+ i = key.end;
656
+ while (i < text.length && text.charCodeAt(i) === 0x20) i++;
657
+ if (text.charCodeAt(i) !== 0x3A /* : */) {
658
+ throw new MdFrontmatterError("expected ':' in flow map", lineNo);
659
+ }
660
+ const value = parseFlowValue(text, i + 1, lineNo);
661
+ setObjectMember(out, String(key.value), value.value);
662
+ i = value.end;
663
+ }
664
+ }
665
+
666
+ // ------------------------------------------------------------------
667
+ // TOML subset (the built-in fallback; inject @jarenjs/josl's parseToml
668
+ // through options.toml for the full language)
669
+ // ------------------------------------------------------------------
670
+
671
+ /**
672
+ * Parse the built-in TOML subset: `[table]` and `[[array-of-tables]]`
673
+ * headers with dotted paths, bare/quoted/dotted keys, basic and literal
674
+ * strings, integers (decimal/hex/octal/binary, `_` separators), floats,
675
+ * booleans, single- or multi-line flow arrays, inline tables, and `#`
676
+ * comments. Datetimes are kept as strings; multi-line strings are not
677
+ * supported (normative limits in docs/MD-FORMAT.md §3.3).
678
+ * @param {string} text
679
+ * @returns {Record<string, any>}
680
+ */
681
+ export function parseTomlSubset(text) {
682
+ /** @type {Record<string, any>} */
683
+ const root = {};
684
+ let table = root;
685
+ const lines = text.split('\n');
686
+ for (let no = 0; no < lines.length; no++) {
687
+ let line = lines[no];
688
+ if (line.endsWith('\r')) line = line.slice(0, -1);
689
+ line = line.trim();
690
+ if (line === '' || line.charCodeAt(0) === 0x23 /* # */) continue;
691
+ if (line.charCodeAt(0) === 0x5B /* [ */) {
692
+ const isArray = line.charCodeAt(1) === 0x5B;
693
+ const close = line.indexOf(isArray ? ']]' : ']');
694
+ if (close === -1) throw new MdFrontmatterError('unterminated table header', no);
695
+ const path = parseTomlKeyPath(line.slice(isArray ? 2 : 1, close), no);
696
+ table = descendTomlTable(root, path, isArray, no);
697
+ continue;
698
+ }
699
+ const eq = findTomlEquals(line);
700
+ if (eq === -1) throw new MdFrontmatterError(`expected 'key = value', got '${line}'`, no);
701
+ const path = parseTomlKeyPath(line.slice(0, eq), no);
702
+ let valueText = line.slice(eq + 1).trim();
703
+ // Multi-line flow arrays / inline tables: join lines while open.
704
+ while (flowDepth(stripTomlComment(valueText)) > 0 && no + 1 < lines.length) {
705
+ valueText += ' ' + lines[++no].trim();
706
+ }
707
+ valueText = stripTomlComment(valueText).trim();
708
+ let target = table;
709
+ for (let i = 0; i < path.length - 1; i++) {
710
+ const step = path[i];
711
+ if (!(step in target) || typeof target[step] !== 'object') {
712
+ const next = {};
713
+ setObjectMember(target, step, next);
714
+ target = next;
715
+ }
716
+ else {
717
+ target = target[step];
718
+ }
719
+ }
720
+ setObjectMember(target, path[path.length - 1], parseTomlValue(valueText, no));
721
+ }
722
+ return root;
723
+ }
724
+
725
+ /**
726
+ * Find the `=` of a key/value line, outside quotes.
727
+ * @param {string} line
728
+ * @returns {number}
729
+ */
730
+ function findTomlEquals(line) {
731
+ let quote = 0;
732
+ for (let i = 0; i < line.length; i++) {
733
+ const c = line.charCodeAt(i);
734
+ if (quote !== 0) {
735
+ if (c === quote) quote = 0;
736
+ }
737
+ else if (c === 0x22 || c === 0x27) quote = c;
738
+ else if (c === 0x3D /* = */) return i;
739
+ }
740
+ return -1;
741
+ }
742
+
743
+ /**
744
+ * Strip a ` # comment` tail outside quotes.
745
+ * @param {string} text
746
+ * @returns {string}
747
+ */
748
+ function stripTomlComment(text) {
749
+ let quote = 0;
750
+ for (let i = 0; i < text.length; i++) {
751
+ const c = text.charCodeAt(i);
752
+ if (quote !== 0) {
753
+ if (c === quote && !(quote === 0x22 && text.charCodeAt(i - 1) === 0x5C)) quote = 0;
754
+ }
755
+ else if (c === 0x22 || c === 0x27) quote = c;
756
+ else if (c === 0x23 /* # */) return text.slice(0, i);
757
+ }
758
+ return text;
759
+ }
760
+
761
+ /**
762
+ * Parse a dotted key path (`a.b."c.d"`).
763
+ * @param {string} text
764
+ * @param {number} no
765
+ * @returns {string[]}
766
+ */
767
+ function parseTomlKeyPath(text, no) {
768
+ /** @type {string[]} */
769
+ const out = [];
770
+ let i = 0;
771
+ while (i < text.length) {
772
+ while (i < text.length && text.charCodeAt(i) === 0x20) i++;
773
+ const c = text.charCodeAt(i);
774
+ if (c === 0x22 || c === 0x27) {
775
+ const q = parseQuoted(text, i, /** @type {'"'|"'"} */ (text[i]));
776
+ out.push(q.value);
777
+ i = q.end;
778
+ }
779
+ else {
780
+ let end = i;
781
+ while (end < text.length) {
782
+ const cc = text.charCodeAt(end);
783
+ if (cc === 0x2E /* . */ || cc === 0x20) break;
784
+ end++;
785
+ }
786
+ if (end === i) throw new MdFrontmatterError('empty key segment', no);
787
+ out.push(text.slice(i, end));
788
+ i = end;
789
+ }
790
+ while (i < text.length && text.charCodeAt(i) === 0x20) i++;
791
+ if (i < text.length) {
792
+ if (text.charCodeAt(i) !== 0x2E) {
793
+ throw new MdFrontmatterError(`unexpected '${text[i]}' in key`, no);
794
+ }
795
+ i++;
796
+ }
797
+ }
798
+ if (out.length === 0) throw new MdFrontmatterError('empty key', no);
799
+ return out;
800
+ }
801
+
802
+ /**
803
+ * Walk (creating) the table a `[header]` names; `[[header]]` appends a
804
+ * fresh table to the named array.
805
+ * @param {Record<string, any>} root
806
+ * @param {string[]} path
807
+ * @param {boolean} isArray
808
+ * @param {number} no
809
+ * @returns {Record<string, any>}
810
+ */
811
+ function descendTomlTable(root, path, isArray, no) {
812
+ let target = root;
813
+ for (let i = 0; i < path.length - 1; i++) {
814
+ const step = path[i];
815
+ let next = target[step];
816
+ if (next === undefined) {
817
+ next = {};
818
+ setObjectMember(target, step, next);
819
+ }
820
+ else if (Array.isArray(next)) {
821
+ next = next[next.length - 1];
822
+ }
823
+ if (typeof next !== 'object' || next === null) {
824
+ throw new MdFrontmatterError(`'${step}' is not a table`, no);
825
+ }
826
+ target = next;
827
+ }
828
+ const leaf = path[path.length - 1];
829
+ if (isArray) {
830
+ let arr = target[leaf];
831
+ if (arr === undefined) {
832
+ arr = [];
833
+ setObjectMember(target, leaf, arr);
834
+ }
835
+ if (!Array.isArray(arr)) throw new MdFrontmatterError(`'${leaf}' is not an array of tables`, no);
836
+ const fresh = {};
837
+ arr.push(fresh);
838
+ return fresh;
839
+ }
840
+ let next = target[leaf];
841
+ if (next === undefined) {
842
+ next = {};
843
+ setObjectMember(target, leaf, next);
844
+ }
845
+ else if (Array.isArray(next)) {
846
+ next = next[next.length - 1];
847
+ }
848
+ if (typeof next !== 'object' || next === null) {
849
+ throw new MdFrontmatterError(`'${leaf}' is not a table`, no);
850
+ }
851
+ return next;
852
+ }
853
+
854
+ /**
855
+ * Parse a TOML value.
856
+ * @param {string} text
857
+ * @param {number} no
858
+ * @returns {any}
859
+ */
860
+ function parseTomlValue(text, no) {
861
+ if (text === '') throw new MdFrontmatterError('missing value', no);
862
+ const c0 = text.charCodeAt(0);
863
+ if (c0 === 0x22 || c0 === 0x27) {
864
+ return parseQuoted(text, 0, /** @type {'"'|"'"} */ (text[0])).value;
865
+ }
866
+ if (c0 === 0x5B /* [ */) return parseTomlArray(text, no).value;
867
+ if (c0 === 0x7B /* { */) return parseTomlInline(text, no).value;
868
+ if (text === 'true') return true;
869
+ if (text === 'false') return false;
870
+ if (RE_TOML_NUMBER.test(text)) {
871
+ const plain = text.replace(/_/g, '');
872
+ return Number(plain);
873
+ }
874
+ // Datetimes and anything else the subset does not model: the verbatim
875
+ // string (the injectable @jarenjs/josl parser models them fully).
876
+ return text;
877
+ }
878
+
879
+ /**
880
+ * Parse a flow array `[a, b, ...]`.
881
+ * @param {string} text
882
+ * @param {number} no
883
+ * @returns {{ value: any[], end: number }}
884
+ */
885
+ function parseTomlArray(text, no) {
886
+ return parseFlowArray(text, 0, no, parseTomlItem, 'unterminated array');
887
+ }
888
+
889
+ /**
890
+ * Parse an inline table `{a = 1, b = 2}`.
891
+ * @param {string} text
892
+ * @param {number} no
893
+ * @returns {{ value: Record<string, any>, end: number }}
894
+ */
895
+ function parseTomlInline(text, no) {
896
+ /** @type {Record<string, any>} */
897
+ const out = {};
898
+ let i = 1;
899
+ for (;;) {
900
+ while (i < text.length && (text.charCodeAt(i) === 0x20 || text.charCodeAt(i) === 0x2C)) i++;
901
+ if (i >= text.length) throw new MdFrontmatterError('unterminated inline table', no);
902
+ if (text.charCodeAt(i) === 0x7D /* } */) return { value: out, end: i + 1 };
903
+ let end = i;
904
+ while (end < text.length && text.charCodeAt(end) !== 0x3D) end++;
905
+ if (end >= text.length) throw new MdFrontmatterError("expected '=' in inline table", no);
906
+ const path = parseTomlKeyPath(text.slice(i, end).trim(), no);
907
+ const item = parseTomlItem(text, end + 1, no);
908
+ let target = out;
909
+ for (let p = 0; p < path.length - 1; p++) {
910
+ const next = {};
911
+ setObjectMember(target, path[p], next);
912
+ target = next;
913
+ }
914
+ setObjectMember(target, path[path.length - 1], item.value);
915
+ i = item.end;
916
+ }
917
+ }
918
+
919
+ /**
920
+ * Parse one value inside a flow array or inline table.
921
+ * @param {string} text
922
+ * @param {number} pos
923
+ * @param {number} no
924
+ * @returns {{ value: any, end: number }}
925
+ */
926
+ function parseTomlItem(text, pos, no) {
927
+ while (pos < text.length && text.charCodeAt(pos) === 0x20) pos++;
928
+ const c = text.charCodeAt(pos);
929
+ if (c === 0x5B /* [ */) {
930
+ const inner = parseTomlArray(text.slice(pos), no);
931
+ return { value: inner.value, end: pos + inner.end };
932
+ }
933
+ if (c === 0x7B /* { */) {
934
+ const inner = parseTomlInline(text.slice(pos), no);
935
+ return { value: inner.value, end: pos + inner.end };
936
+ }
937
+ if (c === 0x22 || c === 0x27) {
938
+ return parseQuoted(text, pos, /** @type {'"'|"'"} */ (text[pos]));
939
+ }
940
+ let end = pos;
941
+ while (end < text.length) {
942
+ const cc = text.charCodeAt(end);
943
+ if (cc === 0x2C || cc === 0x5D || cc === 0x7D) break;
944
+ end++;
945
+ }
946
+ return { value: parseTomlValue(text.slice(pos, end).trim(), no), end };
947
+ }