@orkestrel/markdown 0.0.1 → 0.0.2

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.
@@ -0,0 +1,2056 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _orkestrel_contract = require("@orkestrel/contract");
3
+ //#region src/core/constants.ts
4
+ /**
5
+ * The URL schemes `renderHTML` permits on a link `href` - anything else (notably
6
+ * `javascript:`, `data:`, `vbscript:`, `file:`) is dropped to an empty `href` so a
7
+ * hostile link can never execute. Frozen, lower-case; a relative / anchor /
8
+ * scheme-less `href` (no `scheme:` prefix) is always allowed.
9
+ */
10
+ var SAFE_URL_SCHEMES = /* @__PURE__ */ new Set([
11
+ "http",
12
+ "https",
13
+ "mailto",
14
+ "tel"
15
+ ]);
16
+ /**
17
+ * The maximum recursion depth the parse pipeline (`parseDocument` and its
18
+ * `parsers.ts` helpers) and the `helpers.ts` traversal / render functions
19
+ * (`renderHTML`, `renderMarkdown`, `walkNodes`, `foldNode`) honor before degrading to
20
+ * literal text - bounds blockquote nesting, inline nesting (emphasis / links), and
21
+ * traversal/render recursion so pathological or hostile input (deeply nested
22
+ * blockquotes, runaway emphasis) cannot exhaust the call stack. Past this depth the
23
+ * parser treats the remaining content as literal text instead of recursing further.
24
+ */
25
+ var MAX_DEPTH = 64;
26
+ //#endregion
27
+ //#region src/core/validators.ts
28
+ /**
29
+ * Whether `character` is an inline whitespace character (space / tab / newline) - the
30
+ * emphasis flanking rule's space test.
31
+ *
32
+ * @param character - The character to test
33
+ * @returns `true` when it is inline whitespace
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * isWhitespace(' ') // true
38
+ * isWhitespace('a') // false
39
+ * ```
40
+ */
41
+ function isWhitespace(character) {
42
+ return character === " " || character === " " || character === "\n";
43
+ }
44
+ /**
45
+ * Whether `character` is escapable by a leading backslash - the ASCII punctuation
46
+ * markdown gives meaning to (so `\*` becomes `*` but `\.` stays `\.`).
47
+ *
48
+ * @param character - The single character after a backslash
49
+ * @returns `true` when a backslash before it is an escape
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * isEscapable('*') // true
54
+ * isEscapable('a') // false
55
+ * ```
56
+ */
57
+ function isEscapable(character) {
58
+ return /[\\`*_{}[\]()#+\-.!>~|]/.test(character);
59
+ }
60
+ /**
61
+ * Whether `line` is blank - empty, or containing only whitespace - the markdown
62
+ * definition of a blank line that block parsing uses to separate paragraphs, skip
63
+ * gaps, and end list continuations.
64
+ *
65
+ * @param line - The candidate line
66
+ * @returns `true` when the line is blank
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * isBlankLine(' ') // true
71
+ * ```
72
+ */
73
+ function isBlankLine(line) {
74
+ return (0, _orkestrel_contract.isEmptyString)(line.trim());
75
+ }
76
+ /**
77
+ * Whether `line` is a blockquote line (`>` optionally indented up to three spaces) -
78
+ * its content is de-quoted by {@link stripQuote}.
79
+ *
80
+ * @param line - The candidate line
81
+ * @returns `true` when the line begins a blockquote
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * isQuote('> quoted') // true
86
+ * ```
87
+ */
88
+ function isQuote(line) {
89
+ return /^\s{0,3}>/.test(line);
90
+ }
91
+ /**
92
+ * Whether `line` closes a fence opened by `marker` - the same fence character, a run
93
+ * at least as long, and nothing else but surrounding whitespace.
94
+ *
95
+ * @param line - The candidate closing line
96
+ * @param marker - The opening fence's marker run (from {@link extractFence})
97
+ * @returns `true` when `line` closes the fence
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * isFenceClose('```', '```') // true
102
+ * ```
103
+ */
104
+ function isFenceClose(line, marker) {
105
+ const character = marker[0] === "~" ? "~" : "`";
106
+ let index = 0;
107
+ while (index < line.length && isFenceWhitespace(line[index])) index++;
108
+ let run = 0;
109
+ while (index < line.length && line[index] === character) {
110
+ run++;
111
+ index++;
112
+ }
113
+ if (run < marker.length) return false;
114
+ while (index < line.length && isFenceWhitespace(line[index])) index++;
115
+ return index === line.length;
116
+ }
117
+ /**
118
+ * Whether `character` is a regex-`\s`-equivalent whitespace character - the
119
+ * character class {@link isFenceClose}'s scan treats as surrounding padding.
120
+ *
121
+ * @param character - The single character to test, or `undefined` past the end of a line
122
+ * @returns `true` when it is whitespace
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * isFenceWhitespace(' ') // true
127
+ * isFenceWhitespace(undefined) // false
128
+ * ```
129
+ */
130
+ function isFenceWhitespace(character) {
131
+ return character === " " || character === " " || character === "\n" || character === "\r" || character === "\f" || character === "\v";
132
+ }
133
+ /**
134
+ * Whether `line` is a thematic break (horizontal rule) - three or more of the SAME
135
+ * marker `-`, `*`, or `_` (optionally space-separated) and nothing else (`---`,
136
+ * `***`, `___`, `- - -`).
137
+ *
138
+ * @param line - The candidate line
139
+ * @returns `true` when the line is a thematic break
140
+ *
141
+ * @example
142
+ * ```ts
143
+ * isThematicBreak('---') // true
144
+ * ```
145
+ */
146
+ function isThematicBreak(line) {
147
+ const stripped = line.trim().replace(/\s+/g, "");
148
+ if (stripped.length < 3) return false;
149
+ const marker = stripped[0];
150
+ if (marker !== "-" && marker !== "*" && marker !== "_") return false;
151
+ return [...stripped].every((character) => character === marker);
152
+ }
153
+ /**
154
+ * Whether the pair (`header`, `delimiter`) opens a GFM table - `delimiter` is a row of
155
+ * `|`-separated cells each matching `:?-+:?`, the GFM rule that a table requires a
156
+ * header row IMMEDIATELY followed by a delimiter row.
157
+ *
158
+ * @param header - The candidate header line
159
+ * @param delimiter - The line after it (the candidate delimiter)
160
+ * @returns `true` when the two lines open a table
161
+ *
162
+ * @example
163
+ * ```ts
164
+ * isTableStart('| a |', '| - |') // true
165
+ * ```
166
+ */
167
+ function isTableStart(header, delimiter) {
168
+ if (delimiter === void 0 || !header.includes("|")) return false;
169
+ const cells = splitTableRow(delimiter);
170
+ if (cells.length === 0) return false;
171
+ return cells.every((cell) => /^:?-+:?$/.test(cell.trim()));
172
+ }
173
+ /** Determine whether a node is a heading block. */
174
+ function isHeadingNode(node) {
175
+ return node.element === "heading";
176
+ }
177
+ /**
178
+ * Determine whether a node is a paragraph block.
179
+ *
180
+ * @example
181
+ * ```ts
182
+ * isParagraphNode({ element: 'paragraph', children: [] }) // true
183
+ * ```
184
+ */
185
+ function isParagraphNode(node) {
186
+ return node.element === "paragraph";
187
+ }
188
+ /**
189
+ * Determine whether a node is a list block.
190
+ *
191
+ * @example
192
+ * ```ts
193
+ * isListNode({ element: 'list', ordered: false, start: 1, items: [] }) // true
194
+ * ```
195
+ */
196
+ function isListNode(node) {
197
+ return node.element === "list";
198
+ }
199
+ /** Determine whether a node is a GFM table block. */
200
+ function isTableNode(node) {
201
+ return node.element === "table";
202
+ }
203
+ /**
204
+ * Determine whether a node is a fenced code block.
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * isCodeBlockNode({ element: 'codeBlock', code: 'x' }) // true
209
+ * ```
210
+ */
211
+ function isCodeBlockNode(node) {
212
+ return node.element === "codeBlock";
213
+ }
214
+ /**
215
+ * Determine whether a node is a blockquote block.
216
+ *
217
+ * @example
218
+ * ```ts
219
+ * isBlockquoteNode({ element: 'blockquote', children: [] }) // true
220
+ * ```
221
+ */
222
+ function isBlockquoteNode(node) {
223
+ return node.element === "blockquote";
224
+ }
225
+ /**
226
+ * Determine whether a node is a thematic break (horizontal rule) block.
227
+ *
228
+ * @example
229
+ * ```ts
230
+ * isThematicBreakNode({ element: 'thematicBreak' }) // true
231
+ * ```
232
+ */
233
+ function isThematicBreakNode(node) {
234
+ return node.element === "thematicBreak";
235
+ }
236
+ /**
237
+ * Determine whether a node is a plain text run.
238
+ *
239
+ * @example
240
+ * ```ts
241
+ * isTextNode({ element: 'text', value: 'hi' }) // true
242
+ * ```
243
+ */
244
+ function isTextNode(node) {
245
+ return node.element === "text";
246
+ }
247
+ /**
248
+ * Determine whether a node is an emphasis run (`*em*` / `**strong**`).
249
+ *
250
+ * @example
251
+ * ```ts
252
+ * isEmphasisNode({ element: 'emphasis', strong: false, children: [] }) // true
253
+ * ```
254
+ */
255
+ function isEmphasisNode(node) {
256
+ return node.element === "emphasis";
257
+ }
258
+ /**
259
+ * Determine whether a node is an inline code span.
260
+ *
261
+ * @remarks
262
+ * Narrows to {@link CodeSpanNode} - the node whose `element` discriminant is
263
+ * `'codeSpan'`.
264
+ *
265
+ * @example
266
+ * ```ts
267
+ * isCodeSpanNode({ element: 'codeSpan', value: 'x' }) // true
268
+ * ```
269
+ */
270
+ function isCodeSpanNode(node) {
271
+ return node.element === "codeSpan";
272
+ }
273
+ /** Determine whether a node is a link. */
274
+ function isLinkNode(node) {
275
+ return node.element === "link";
276
+ }
277
+ /**
278
+ * Determine whether an arbitrary value is a valid {@link InlineNode} - a text
279
+ * run, emphasis, code span, or link, recursively validated.
280
+ *
281
+ * @remarks
282
+ * Total: never throws, even on cyclic or pathologically deep input - every
283
+ * combinator involved (`unionOf`, `recordOf`, `arrayOf`, `lazyOf`) is
284
+ * throw-contained per the `@orkestrel/contract` guard contract (AGENTS §14).
285
+ *
286
+ * @param value - The value to test
287
+ * @returns `true` when `value` is a well-formed {@link InlineNode}
288
+ *
289
+ * @example
290
+ * ```ts
291
+ * import { isInlineNode } from '@orkestrel/markdown'
292
+ *
293
+ * isInlineNode({ element: 'text', value: 'hi' }) // true
294
+ * isInlineNode({ element: 'text' }) // false - missing `value`
295
+ * ```
296
+ */
297
+ var isInlineNode = (0, _orkestrel_contract.unionOf)((0, _orkestrel_contract.recordOf)({
298
+ element: (0, _orkestrel_contract.literalOf)("text"),
299
+ value: _orkestrel_contract.isString
300
+ }), (0, _orkestrel_contract.recordOf)({
301
+ element: (0, _orkestrel_contract.literalOf)("emphasis"),
302
+ strong: _orkestrel_contract.isBoolean,
303
+ children: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.lazyOf)(() => isInlineNode))
304
+ }), (0, _orkestrel_contract.recordOf)({
305
+ element: (0, _orkestrel_contract.literalOf)("codeSpan"),
306
+ value: _orkestrel_contract.isString
307
+ }), (0, _orkestrel_contract.recordOf)({
308
+ element: (0, _orkestrel_contract.literalOf)("link"),
309
+ href: _orkestrel_contract.isString,
310
+ children: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.lazyOf)(() => isInlineNode))
311
+ }));
312
+ /**
313
+ * Determine whether an arbitrary value is a valid {@link BlockNode} - a
314
+ * heading, paragraph, list, table, code block, blockquote, or thematic break,
315
+ * recursively validated.
316
+ *
317
+ * @remarks
318
+ * Total: never throws, even on cyclic or pathologically deep input - every
319
+ * combinator involved (`unionOf`, `recordOf`, `arrayOf`, `lazyOf`) is
320
+ * throw-contained per the `@orkestrel/contract` guard contract (AGENTS §14).
321
+ * A list item's shape is inlined here (and in {@link isMarkdownNode}) rather
322
+ * than named separately - it is used at exactly these two sites.
323
+ *
324
+ * @param value - The value to test
325
+ * @returns `true` when `value` is a well-formed {@link BlockNode}
326
+ *
327
+ * @example
328
+ * ```ts
329
+ * import { isBlockNode } from '@orkestrel/markdown'
330
+ *
331
+ * isBlockNode({ element: 'thematicBreak' }) // true
332
+ * isBlockNode({ element: 'heading' }) // false - missing `level` / `children`
333
+ * ```
334
+ */
335
+ var isBlockNode = (0, _orkestrel_contract.unionOf)((0, _orkestrel_contract.recordOf)({
336
+ element: (0, _orkestrel_contract.literalOf)("heading"),
337
+ level: _orkestrel_contract.isNumber,
338
+ children: (0, _orkestrel_contract.arrayOf)(isInlineNode)
339
+ }), (0, _orkestrel_contract.recordOf)({
340
+ element: (0, _orkestrel_contract.literalOf)("paragraph"),
341
+ children: (0, _orkestrel_contract.arrayOf)(isInlineNode)
342
+ }), (0, _orkestrel_contract.recordOf)({
343
+ element: (0, _orkestrel_contract.literalOf)("list"),
344
+ ordered: _orkestrel_contract.isBoolean,
345
+ start: _orkestrel_contract.isNumber,
346
+ items: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.recordOf)({
347
+ element: (0, _orkestrel_contract.literalOf)("listItem"),
348
+ children: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.lazyOf)(() => isBlockNode))
349
+ }))
350
+ }), (0, _orkestrel_contract.recordOf)({
351
+ element: (0, _orkestrel_contract.literalOf)("table"),
352
+ header: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.arrayOf)(isInlineNode)),
353
+ rows: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.arrayOf)(isInlineNode))),
354
+ align: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.literalOf)("none", "left", "right", "center"))
355
+ }), (0, _orkestrel_contract.recordOf)({
356
+ element: (0, _orkestrel_contract.literalOf)("codeBlock"),
357
+ lang: _orkestrel_contract.isString,
358
+ code: _orkestrel_contract.isString
359
+ }, ["lang"]), (0, _orkestrel_contract.recordOf)({
360
+ element: (0, _orkestrel_contract.literalOf)("blockquote"),
361
+ children: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.lazyOf)(() => isBlockNode))
362
+ }), (0, _orkestrel_contract.recordOf)({ element: (0, _orkestrel_contract.literalOf)("thematicBreak") }));
363
+ /**
364
+ * Determine whether an arbitrary value is a valid {@link MarkdownNode} - the
365
+ * {@link MarkdownDocument} root, a {@link BlockNode}, a {@link ListItemNode}, or
366
+ * an {@link InlineNode}, recursively validated.
367
+ *
368
+ * @remarks
369
+ * Total: never throws, even on cyclic or pathologically deep input - every
370
+ * combinator involved (`unionOf`, `recordOf`, `arrayOf`, `lazyOf`) is
371
+ * throw-contained per the `@orkestrel/contract` guard contract (AGENTS §14).
372
+ * A list item's shape is inlined here (and in {@link isBlockNode}) rather than
373
+ * named separately - it is used at exactly these two sites.
374
+ *
375
+ * @param value - The value to test
376
+ * @returns `true` when `value` is a well-formed {@link MarkdownNode}
377
+ *
378
+ * @example
379
+ * ```ts
380
+ * import { isMarkdownNode } from '@orkestrel/markdown'
381
+ *
382
+ * isMarkdownNode({ element: 'text', value: 'hi' }) // true
383
+ * isMarkdownNode({ element: 'bogus' }) // false
384
+ * ```
385
+ */
386
+ var isMarkdownNode = (0, _orkestrel_contract.unionOf)((0, _orkestrel_contract.lazyOf)(() => isMarkdownDocument), (0, _orkestrel_contract.lazyOf)(() => isBlockNode), (0, _orkestrel_contract.recordOf)({
387
+ element: (0, _orkestrel_contract.literalOf)("listItem"),
388
+ children: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.lazyOf)(() => isBlockNode))
389
+ }), (0, _orkestrel_contract.lazyOf)(() => isInlineNode));
390
+ /**
391
+ * Determine whether an arbitrary value is a valid {@link MarkdownDocument} -
392
+ * the parsed-AST root {@link parseDocument} returns, recursively
393
+ * validated.
394
+ *
395
+ * @remarks
396
+ * Total: never throws, even on cyclic or pathologically deep input - every
397
+ * combinator involved (`recordOf`, `arrayOf`) is throw-contained per the
398
+ * `@orkestrel/contract` guard contract (AGENTS §14).
399
+ *
400
+ * @param value - The value to test
401
+ * @returns `true` when `value` is a well-formed {@link MarkdownDocument}
402
+ *
403
+ * @example
404
+ * ```ts
405
+ * import { isMarkdownDocument } from '@orkestrel/markdown'
406
+ *
407
+ * isMarkdownDocument({ element: 'document', children: [] }) // true
408
+ * isMarkdownDocument({ element: 'document' }) // false - missing `children`
409
+ * ```
410
+ */
411
+ var isMarkdownDocument = (0, _orkestrel_contract.recordOf)({
412
+ element: (0, _orkestrel_contract.literalOf)("document"),
413
+ children: (0, _orkestrel_contract.arrayOf)(isBlockNode)
414
+ });
415
+ //#endregion
416
+ //#region src/core/helpers.ts
417
+ /**
418
+ * Normalize line endings to `\n` and split a markdown document into its lines - CRLF
419
+ * (`\r\n`) and bare CR (`\r`) both collapse to `\n` first, so a Windows-origin
420
+ * document parses identically. A single trailing newline does not yield a final
421
+ * empty line.
422
+ *
423
+ * @param markdown - The raw markdown source
424
+ * @returns The document's lines, line-terminators stripped
425
+ *
426
+ * @example
427
+ * ```ts
428
+ * splitLines('a\r\nb\nc') // ['a', 'b', 'c']
429
+ * ```
430
+ */
431
+ function splitLines(markdown) {
432
+ const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
433
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
434
+ return lines;
435
+ }
436
+ /**
437
+ * The count of leading space / tab characters on `line` (a tab counts as one) - the
438
+ * indent that decides whether a list item's continuation belongs to the item.
439
+ *
440
+ * @param line - The line to measure
441
+ * @returns The number of leading space / tab characters
442
+ *
443
+ * @example
444
+ * ```ts
445
+ * leadingIndent(' text') // 2
446
+ * ```
447
+ */
448
+ function leadingIndent(line) {
449
+ let count = 0;
450
+ for (const character of line) if (character === " " || character === " ") count += 1;
451
+ else break;
452
+ return count;
453
+ }
454
+ /**
455
+ * Extract an ATX heading line (`#` … `######` followed by text) into its
456
+ * `{ level, text }`, or `undefined` when `line` is not a heading. A run of more than 6
457
+ * `#`s, or `#`s not followed by whitespace + text, is not a
458
+ * heading; an optional closing `###` run is stripped.
459
+ *
460
+ * @param line - The candidate line
461
+ * @returns The heading level (1–6) and its raw inline text, or `undefined`
462
+ *
463
+ * @example
464
+ * ```ts
465
+ * extractHeading('## Title') // { level: 2, text: 'Title' }
466
+ * ```
467
+ */
468
+ function extractHeading(line) {
469
+ const match = /^(#{1,6})(?:\s+(.*))?$/.exec(line.trimStart());
470
+ if (!match || match[1] === void 0) return void 0;
471
+ return {
472
+ level: match[1].length,
473
+ text: (match[2] ?? "").replace(/\s+#+\s*$/, "").trim()
474
+ };
475
+ }
476
+ /**
477
+ * Extract a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
478
+ * string) into its `{ marker, lang }`, or `undefined` when `line` is not a fence
479
+ * opener. `marker` is the exact fence run (the closer must match the same character +
480
+ * at least the same length); `lang` is the first word of the info string.
481
+ *
482
+ * @param line - The candidate line
483
+ * @returns The fence marker run and its language tag, or `undefined`
484
+ *
485
+ * @example
486
+ * ```ts
487
+ * extractFence('```ts') // { marker: '```', lang: 'ts' }
488
+ * ```
489
+ */
490
+ function extractFence(line) {
491
+ const match = /^\s*(`{3,}|~{3,})\s*(.*)$/.exec(line);
492
+ if (!match || match[1] === void 0) return void 0;
493
+ const info = (match[2] ?? "").trim();
494
+ if (match[1].startsWith("`") && info.includes("`")) return void 0;
495
+ const lang = (0, _orkestrel_contract.isNonEmptyString)(info) ? info.split(/\s+/)[0] : void 0;
496
+ return {
497
+ marker: match[1],
498
+ lang
499
+ };
500
+ }
501
+ /**
502
+ * Extract a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
503
+ * a space) into its {@link ListItemParts}, or `undefined` when `line` is not a list
504
+ * item. `content` is the text after the marker; `marker` is the full marker-plus-space
505
+ * width (for measuring a continuation's indent).
506
+ *
507
+ * @param line - The candidate line
508
+ * @returns The list-item parts, or `undefined` when not a list item
509
+ *
510
+ * @example
511
+ * ```ts
512
+ * extractListItem('- item') // { ordered: false, start: 1, content: 'item', indent: 0, marker: 2 }
513
+ * ```
514
+ */
515
+ function extractListItem(line) {
516
+ const unordered = /^(\s*)([-*+])\s+(.*)$/.exec(line);
517
+ if (unordered && unordered[1] !== void 0) {
518
+ const indent = unordered[1].length;
519
+ const content = unordered[3] ?? "";
520
+ return {
521
+ ordered: false,
522
+ start: 1,
523
+ content,
524
+ indent,
525
+ marker: line.length - content.length
526
+ };
527
+ }
528
+ const ordered = /^(\s*)(\d{1,9})[.)]\s+(.*)$/.exec(line);
529
+ if (ordered && ordered[1] !== void 0 && ordered[2] !== void 0) {
530
+ const indent = ordered[1].length;
531
+ const content = ordered[3] ?? "";
532
+ return {
533
+ ordered: true,
534
+ start: (0, _orkestrel_contract.parseInteger)(ordered[2]) ?? 1,
535
+ content,
536
+ indent,
537
+ marker: line.length - content.length
538
+ };
539
+ }
540
+ }
541
+ /**
542
+ * Strip one level of blockquote marker (`>` plus one optional following space) from a
543
+ * blockquote line, so the de-quoted lines re-parse as nested blocks.
544
+ *
545
+ * @param line - A blockquote line (per {@link isQuote})
546
+ * @returns The line with its leading `>` (and one space) removed
547
+ *
548
+ * @example
549
+ * ```ts
550
+ * stripQuote('> text') // 'text'
551
+ * ```
552
+ */
553
+ function stripQuote(line) {
554
+ return line.replace(/^\s{0,3}>\s?/, "");
555
+ }
556
+ /**
557
+ * Split one GFM table row into its cell strings - outer pipes are optional, an escaped
558
+ * pipe (`\|`) inside a cell is NOT a separator (it becomes a literal `|`), and the
559
+ * empty leading / trailing cell produced by an outer `|` is dropped.
560
+ *
561
+ * @param row - The raw table row line
562
+ * @returns The row's cells, in column order
563
+ *
564
+ * @example
565
+ * ```ts
566
+ * splitTableRow('|a|b|') // ['a', 'b']
567
+ * ```
568
+ */
569
+ function splitTableRow(row) {
570
+ const cells = [];
571
+ let current = "";
572
+ const trimmed = row.trim();
573
+ for (let index = 0; index < trimmed.length; index += 1) {
574
+ const character = trimmed[index];
575
+ if (character === "\\" && trimmed[index + 1] === "|") {
576
+ current += "|";
577
+ index += 1;
578
+ } else if (character === "|") {
579
+ cells.push(current);
580
+ current = "";
581
+ } else current += character;
582
+ }
583
+ cells.push(current);
584
+ if ((0, _orkestrel_contract.isNonEmptyArray)(cells) && (0, _orkestrel_contract.isEmptyString)((cells[0] ?? "").trim())) cells.shift();
585
+ if ((0, _orkestrel_contract.isNonEmptyArray)(cells) && (0, _orkestrel_contract.isEmptyString)((cells[cells.length - 1] ?? "").trim())) cells.pop();
586
+ return cells;
587
+ }
588
+ /**
589
+ * Derive the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
590
+ * left, `---:` right, `:---:` center, `---` none.
591
+ *
592
+ * @param delimiter - The table's delimiter row
593
+ * @returns One alignment per column, in column order
594
+ *
595
+ * @example
596
+ * ```ts
597
+ * tableAlignments('| :--- | ---: |') // ['left', 'right']
598
+ * ```
599
+ */
600
+ function tableAlignments(delimiter) {
601
+ return splitTableRow(delimiter).map((cell) => {
602
+ const text = cell.trim();
603
+ const left = text.startsWith(":");
604
+ const right = text.endsWith(":");
605
+ if (left && right) return "center";
606
+ if (right) return "right";
607
+ if (left) return "left";
608
+ return "none";
609
+ });
610
+ }
611
+ /**
612
+ * Whether the line at `index` starts a NEW block kind (heading / fence / thematic
613
+ * break / blockquote / list / table) - the paragraph collector stops at such a line
614
+ * so a block following a paragraph without a blank line still parses (a trusted-input
615
+ * caller writing a `##` heading directly under a paragraph, with no intervening blank
616
+ * line).
617
+ *
618
+ * @param lines - The document's lines
619
+ * @param index - The line index to test
620
+ * @returns `true` when the line begins a different block
621
+ *
622
+ * @example
623
+ * ```ts
624
+ * startsBlock(['text', '## Heading'], 1) // true
625
+ * ```
626
+ */
627
+ function startsBlock(lines, index) {
628
+ const line = lines[index] ?? "";
629
+ return extractHeading(line) !== void 0 || extractFence(line) !== void 0 || isThematicBreak(line) || isQuote(line) || extractListItem(line) !== void 0 || isTableStart(line, lines[index + 1]);
630
+ }
631
+ /**
632
+ * Resolve backslash escapes in a raw string to their literal characters - used for a
633
+ * link `href` (which is not otherwise inline-parsed) and any plain text run.
634
+ *
635
+ * @param text - The raw text possibly carrying `\x` escapes
636
+ * @returns The text with escapable `\x` reduced to `x`
637
+ *
638
+ * @example
639
+ * ```ts
640
+ * unescapeText('\\*hi\\*') // '*hi*'
641
+ * ```
642
+ */
643
+ function unescapeText(text) {
644
+ let out = "";
645
+ for (let index = 0; index < text.length; index += 1) {
646
+ const character = text[index] ?? "";
647
+ if (character === "\\" && isEscapable(text[index + 1] ?? "")) {
648
+ out += text[index + 1] ?? "";
649
+ index += 1;
650
+ } else out += character;
651
+ }
652
+ return out;
653
+ }
654
+ /**
655
+ * Merge adjacent text nodes into one - the inline scanner emits a text node per
656
+ * unrecognized character, so coalescing keeps the AST clean and assertion-friendly.
657
+ *
658
+ * @param nodes - The inline nodes (possibly with adjacent text runs)
659
+ * @returns The nodes with consecutive text nodes concatenated
660
+ *
661
+ * @example
662
+ * ```ts
663
+ * coalesceText([{ element: 'text', value: 'a' }, { element: 'text', value: 'b' }])
664
+ * // [{ element: 'text', value: 'ab' }]
665
+ * ```
666
+ */
667
+ function coalesceText(nodes) {
668
+ const out = [];
669
+ for (const node of nodes) {
670
+ const last = out[out.length - 1];
671
+ if (node.element === "text" && last !== void 0 && last.element === "text") out[out.length - 1] = {
672
+ element: "text",
673
+ value: last.value + node.value
674
+ };
675
+ else out.push(node);
676
+ }
677
+ return out;
678
+ }
679
+ /**
680
+ * Scan an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
681
+ * SAME length, the CommonMark rule that lets a span contain backticks). Returns the
682
+ * span's literal text + end index, or `undefined` when no matching closer exists (it
683
+ * then degrades to literal backticks).
684
+ *
685
+ * @param source - The inline source text
686
+ * @param start - The index of the opening backtick
687
+ * @param to - The exclusive end of the scan window
688
+ * @returns The span text + end index, or `undefined`
689
+ *
690
+ * @example
691
+ * ```ts
692
+ * scanCode('`code`', 0, 6) // { value: 'code', end: 6 }
693
+ * ```
694
+ */
695
+ function scanCode(source, start, to) {
696
+ let run = 0;
697
+ while (start + run < to && source[start + run] === "`") run += 1;
698
+ const open = "`".repeat(run);
699
+ let search = start + run;
700
+ for (;;) {
701
+ const closeAt = source.indexOf(open, search);
702
+ if (closeAt === -1 || closeAt + run > to) return void 0;
703
+ if (source[closeAt - 1] !== "`" && source[closeAt + run] !== "`") {
704
+ let value = source.slice(start + run, closeAt);
705
+ if (value.length > 2 && value.startsWith(" ") && value.endsWith(" ") && value.trim().length > 0) value = value.slice(1, -1);
706
+ return {
707
+ value,
708
+ end: closeAt + run
709
+ };
710
+ }
711
+ search = closeAt + 1;
712
+ }
713
+ }
714
+ /**
715
+ * Scan a link `[text](href)` at `start` - the text runs to a BALANCED `]`, then `(`
716
+ * must immediately follow and the destination runs to the matching `)` (both respect
717
+ * nested delimiters + escapes). Returns the link node, or `undefined` when the shape
718
+ * does not hold (it then degrades to a literal `[`).
719
+ *
720
+ * @param source - The inline source text
721
+ * @param start - The index of the opening `[`
722
+ * @param to - The exclusive end of the scan window
723
+ * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
724
+ * at {@link MAX_DEPTH} the link's text children degrade to literal text instead of
725
+ * recursing further
726
+ * @returns The parsed {@link LinkNode} + end index, or `undefined`
727
+ *
728
+ * @example
729
+ * ```ts
730
+ * scanLink('[text](url)', 0, 11)
731
+ * // { node: { element: 'link', href: 'url', children: [...] }, end: 11 }
732
+ * ```
733
+ */
734
+ function scanLink(source, start, to, depth = 0) {
735
+ let bracketDepth = 0;
736
+ let close = -1;
737
+ for (let index = start; index < to; index += 1) {
738
+ const character = source[index] ?? "";
739
+ if (character === "\\") {
740
+ index += 1;
741
+ continue;
742
+ }
743
+ if (character === "[") bracketDepth += 1;
744
+ else if (character === "]") {
745
+ bracketDepth -= 1;
746
+ if (bracketDepth === 0) {
747
+ close = index;
748
+ break;
749
+ }
750
+ }
751
+ }
752
+ if (close === -1 || source[close + 1] !== "(") return void 0;
753
+ let parenDepth = 0;
754
+ let parenClose = -1;
755
+ for (let index = close + 1; index < to; index += 1) {
756
+ const character = source[index] ?? "";
757
+ if (character === "\\") {
758
+ index += 1;
759
+ continue;
760
+ }
761
+ if (character === "(") parenDepth += 1;
762
+ else if (character === ")") {
763
+ parenDepth -= 1;
764
+ if (parenDepth === 0) {
765
+ parenClose = index;
766
+ break;
767
+ }
768
+ }
769
+ }
770
+ if (parenClose === -1) return void 0;
771
+ return {
772
+ node: {
773
+ element: "link",
774
+ href: unescapeText(source.slice(close + 2, parenClose).trim()),
775
+ children: scanInline(source, start + 1, close, depth + 1)
776
+ },
777
+ end: parenClose + 1
778
+ };
779
+ }
780
+ /**
781
+ * Scan an emphasis run at `start` (`*` / `_`, doubled for strong) - finds the nearest
782
+ * matching closing run of the same marker + width, requiring non-space immediately
783
+ * inside both delimiters (the CommonMark flanking simplification that blocks `* x *`).
784
+ * Returns the emphasis node, or `undefined` when no valid closer exists (it then
785
+ * degrades to a literal marker).
786
+ *
787
+ * @param source - The inline source text
788
+ * @param start - The index of the opening marker
789
+ * @param to - The exclusive end of the scan window
790
+ * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
791
+ * at {@link MAX_DEPTH} the emphasis's children degrade to literal text instead of
792
+ * recursing further
793
+ * @returns The parsed {@link EmphasisNode} + end index, or `undefined`
794
+ *
795
+ * @example
796
+ * ```ts
797
+ * scanEmphasis('*em*', 0, 4)
798
+ * // { node: { element: 'emphasis', strong: false, children: [...] }, end: 4 }
799
+ * ```
800
+ */
801
+ function scanEmphasis(source, start, to, depth = 0) {
802
+ const marker = source[start] ?? "";
803
+ let run = 0;
804
+ while (start + run < to && source[start + run] === marker && run < 2) run += 1;
805
+ const strong = run === 2;
806
+ const openEnd = start + run;
807
+ if (openEnd >= to || isWhitespace(source[openEnd] ?? "")) return void 0;
808
+ let index = openEnd;
809
+ while (index < to) {
810
+ const character = source[index] ?? "";
811
+ if (character === "\\") {
812
+ index += 2;
813
+ continue;
814
+ }
815
+ if (character === "`") {
816
+ const span = scanCode(source, index, to);
817
+ index = span ? span.end : index + 1;
818
+ continue;
819
+ }
820
+ if (character === marker) {
821
+ let closeRun = 0;
822
+ while (index + closeRun < to && source[index + closeRun] === marker) closeRun += 1;
823
+ if (closeRun >= run && !isWhitespace(source[index - 1] ?? "")) return {
824
+ node: {
825
+ element: "emphasis",
826
+ strong,
827
+ children: scanInline(source, openEnd, index, depth + 1)
828
+ },
829
+ end: index + run
830
+ };
831
+ index += closeRun;
832
+ continue;
833
+ }
834
+ index += 1;
835
+ }
836
+ }
837
+ /**
838
+ * Scan the window `[from, to)` of `source` into inline nodes - the single recursive
839
+ * engine the inline phase runs on (emphasis / link text recurse through it). Linear:
840
+ * each character is consumed once; a failed construct emits its opening character as
841
+ * text and advances by one, so there is no re-scan (no ReDoS).
842
+ *
843
+ * @param source - The inline source text
844
+ * @param from - The inclusive start of the scan window
845
+ * @param to - The exclusive end of the scan window
846
+ * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
847
+ * incremented by one on every recursive descent through {@link scanLink} /
848
+ * {@link scanEmphasis}. At {@link MAX_DEPTH} the window is never scanned for markup -
849
+ * it emits as a single literal text node - so pathological nesting (`[[[[…`,
850
+ * `****…`) cannot exhaust the call stack.
851
+ * @returns The parsed inline nodes (NOT yet coalesced)
852
+ *
853
+ * @example
854
+ * ```ts
855
+ * scanInline('hi *there*', 0, 10) // [{ element: 'text', value: 'hi ' }, { element: 'emphasis', ... }]
856
+ * ```
857
+ */
858
+ function scanInline(source, from, to, depth = 0) {
859
+ if (depth >= 64) return from < to ? [{
860
+ element: "text",
861
+ value: source.slice(from, to)
862
+ }] : [];
863
+ const nodes = [];
864
+ let index = from;
865
+ let pending = "";
866
+ const flush = () => {
867
+ if (pending.length > 0) {
868
+ nodes.push({
869
+ element: "text",
870
+ value: pending
871
+ });
872
+ pending = "";
873
+ }
874
+ };
875
+ while (index < to) {
876
+ const character = source[index] ?? "";
877
+ if (character === "\\" && index + 1 < to && isEscapable(source[index + 1] ?? "")) {
878
+ pending += source[index + 1] ?? "";
879
+ index += 2;
880
+ continue;
881
+ }
882
+ if (character === "`") {
883
+ const span = scanCode(source, index, to);
884
+ if (span) {
885
+ flush();
886
+ nodes.push({
887
+ element: "codeSpan",
888
+ value: span.value
889
+ });
890
+ index = span.end;
891
+ continue;
892
+ }
893
+ }
894
+ if (character === "[") {
895
+ const link = scanLink(source, index, to, depth);
896
+ if (link) {
897
+ flush();
898
+ nodes.push(link.node);
899
+ index = link.end;
900
+ continue;
901
+ }
902
+ }
903
+ if (character === "*" || character === "_") {
904
+ const emphasis = scanEmphasis(source, index, to, depth);
905
+ if (emphasis) {
906
+ flush();
907
+ nodes.push(emphasis.node);
908
+ index = emphasis.end;
909
+ continue;
910
+ }
911
+ }
912
+ pending += character;
913
+ index += 1;
914
+ }
915
+ flush();
916
+ return nodes;
917
+ }
918
+ /**
919
+ * HTML-escape text content - `&` / `<` / `>` / `"` / `'` to their entities - so text
920
+ * from a markdown document can never inject markup. The renderer applies this to every
921
+ * text run, code body, and (escaped further) attribute value.
922
+ *
923
+ * @param text - The raw text
924
+ * @returns The HTML-escaped text
925
+ *
926
+ * @example
927
+ * ```ts
928
+ * escapeHtml('<a>&"\'') // '&lt;a&gt;&amp;&quot;&#39;'
929
+ * ```
930
+ */
931
+ function escapeHtml(text) {
932
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
933
+ }
934
+ /**
935
+ * Sanitize + HTML-attribute-escape a link `href` - a destination whose scheme is not
936
+ * in {@link SAFE_URL_SCHEMES} (notably `javascript:` / `data:` / `vbscript:`), or that
937
+ * is protocol-relative (`//host/path`, or a backslash variant a browser normalizes to
938
+ * the same effect - `\\host`, `/\host`, `\/host` - inherits whatever scheme the
939
+ * embedding page is served over, including an unsafe one), is dropped to an empty
940
+ * string; a relative / anchor / scheme-less (and non-protocol-relative) destination
941
+ * (including a SINGLE leading `/` or `\`) is kept;
942
+ * the surviving value is then HTML-escaped. Defence-in-depth against an XSS `href`,
943
+ * even though the input is trusted.
944
+ *
945
+ * @param href - The raw link destination
946
+ * @returns A safe, escaped `href` (empty when the scheme is unsafe or protocol-relative)
947
+ *
948
+ * @example
949
+ * ```ts
950
+ * sanitizeUrl('javascript:alert(1)') // ''
951
+ * sanitizeUrl('/path') // '/path'
952
+ * ```
953
+ */
954
+ function sanitizeUrl(href) {
955
+ let cleaned = "";
956
+ for (const character of href) {
957
+ const code = character.codePointAt(0) ?? 0;
958
+ if (code > 32 && !(code >= 127 && code <= 159)) cleaned += character;
959
+ }
960
+ if (/^[/\\]{2}/.exec(cleaned)) return "";
961
+ const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned);
962
+ if (scheme && scheme[1] !== void 0 && !SAFE_URL_SCHEMES.has(scheme[1].toLowerCase())) return "";
963
+ return escapeHtml(cleaned);
964
+ }
965
+ /**
966
+ * Render a {@link MarkdownNode} (typically a {@link MarkdownDocument}) to a safe HTML
967
+ * string - the recursive AST → HTML engine (headings, paragraphs, lists, GFM tables,
968
+ * fenced code, blockquotes, links, emphasis, inline code), escaping every text run and
969
+ * sanitizing every link `href`.
970
+ *
971
+ * @remarks
972
+ * Total: never throws. At {@link MAX_DEPTH} a value-bearing node (`text` / `codeSpan`)
973
+ * degrades to its escaped `value`; any other node degrades to `''` instead of
974
+ * recursing further, so pathologically deep input cannot exhaust the call stack. The
975
+ * recursive engine and its per-shape sub-steps (inline concatenation, table cell,
976
+ * tight list-item) are nested inner functions - the only exported surface is
977
+ * `renderHTML` itself.
978
+ *
979
+ * @param node - The AST node to render (a full document, or any sub-node)
980
+ * @returns The rendered, XSS-safe HTML string
981
+ *
982
+ * @example
983
+ * ```ts
984
+ * renderHTML({ element: 'document', children: [
985
+ * { element: 'heading', level: 1, children: [{ element: 'text', value: 'Hi' }] },
986
+ * ] })
987
+ * // '<h1>Hi</h1>'
988
+ * ```
989
+ */
990
+ function renderHTML(node) {
991
+ function render(current, depth) {
992
+ if (depth >= 64) return "value" in current && typeof current.value === "string" ? escapeHtml(current.value) : "";
993
+ switch (current.element) {
994
+ case "document": return current.children.map((child) => render(child, depth + 1)).join("\n");
995
+ case "heading": return `<h${current.level}>${renderInline(current.children, depth)}</h${current.level}>`;
996
+ case "paragraph": return `<p>${renderInline(current.children, depth)}</p>`;
997
+ case "thematicBreak": return "<hr>";
998
+ case "blockquote": return `<blockquote>\n${current.children.map((child) => render(child, depth + 1)).join("\n")}\n</blockquote>`;
999
+ case "codeBlock": return `<pre>${current.lang === void 0 ? "<code>" : `<code class="language-${escapeHtml(current.lang)}">`}${escapeHtml(current.code)}</code></pre>`;
1000
+ case "list": {
1001
+ const items = current.items.map((item) => render(item, depth + 1)).join("\n");
1002
+ if (!current.ordered) return `<ul>\n${items}\n</ul>`;
1003
+ return `<ol${current.start !== 1 ? ` start="${current.start}"` : ""}>\n${items}\n</ol>`;
1004
+ }
1005
+ case "listItem": return `<li>${renderItem(current.children, depth)}</li>`;
1006
+ case "table": {
1007
+ const head = `<tr>${current.header.map((cell, column) => renderCell("th", cell, current.align[column], depth)).join("")}</tr>`;
1008
+ const body = current.rows.map((row) => `<tr>${row.map((cell, column) => renderCell("td", cell, current.align[column], depth)).join("")}</tr>`).join("\n");
1009
+ return `<table>\n<thead>\n${head}\n</thead>${(0, _orkestrel_contract.isNonEmptyArray)(current.rows) ? `\n<tbody>\n${body}\n</tbody>` : ""}\n</table>`;
1010
+ }
1011
+ case "text": return escapeHtml(current.value);
1012
+ case "emphasis": return current.strong ? `<strong>${renderInline(current.children, depth + 1)}</strong>` : `<em>${renderInline(current.children, depth + 1)}</em>`;
1013
+ case "codeSpan": return `<code>${escapeHtml(current.value)}</code>`;
1014
+ case "link": return `<a href="${sanitizeUrl(current.href)}">${renderInline(current.children, depth + 1)}</a>`;
1015
+ default: return "";
1016
+ }
1017
+ }
1018
+ function renderInline(nodes, depth) {
1019
+ return nodes.map((child) => render(child, depth + 1)).join("");
1020
+ }
1021
+ function renderCell(tag, cell, align, depth) {
1022
+ return `<${tag}${align === "left" || align === "right" || align === "center" ? ` style="text-align:${align}"` : ""}>${renderInline(cell, depth + 1)}</${tag}>`;
1023
+ }
1024
+ function renderItem(children, depth) {
1025
+ if (children.length === 1) {
1026
+ const only = children[0];
1027
+ if (only !== void 0 && only.element === "paragraph") return renderInline(only.children, depth);
1028
+ }
1029
+ return children.map((child) => render(child, depth + 1)).join("\n");
1030
+ }
1031
+ return render(node, 0);
1032
+ }
1033
+ /**
1034
+ * Render a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
1035
+ * projection of `renderHTML`, and the serializer a `parse(renderMarkdown(doc))`
1036
+ * round-trip is built on. Canonical forms: `*em*` / `**strong**` (underscore emphasis
1037
+ * normalizes to asterisks), `- ` bullets, `N. ` sequential ordinals (from the list's
1038
+ * `start`), `---` thematic breaks, fenced code blocks (backtick run widened past any
1039
+ * 3+ backtick run inside the body), ATX headings, `> `-prefixed blockquote lines, GFM
1040
+ * tables (1-space-padded cells, `\|`-escaped pipes, an alignment delimiter row), and
1041
+ * `[text](href)` links. A `text` node's literal content is backslash-escaped wherever
1042
+ * it would otherwise re-parse as markup (AGENTS §14 parse↔render soundness).
1043
+ *
1044
+ * @remarks
1045
+ * Total: never throws. At {@link MAX_DEPTH} a value-bearing node degrades to its
1046
+ * escaped `value`; any other node degrades to `''`. Blocks are joined by exactly one
1047
+ * blank line; a document with zero blocks renders `''`.
1048
+ *
1049
+ * @param node - The AST node to render (a full document, or any sub-node)
1050
+ * @returns The canonical markdown source
1051
+ *
1052
+ * @example
1053
+ * ```ts
1054
+ * renderMarkdown({ element: 'document', children: [
1055
+ * { element: 'heading', level: 2, children: [{ element: 'text', value: 'Hi' }] },
1056
+ * ] })
1057
+ * // '## Hi'
1058
+ * ```
1059
+ */
1060
+ function renderMarkdown(node) {
1061
+ function escapeText(value) {
1062
+ let out = "";
1063
+ for (let index = 0; index < value.length; index += 1) {
1064
+ const character = value[index] ?? "";
1065
+ const atLineStart = index === 0 || value[index - 1] === "\n";
1066
+ if (character === "\\" || character === "*" || character === "_" || character === "`" || character === "[" || character === "]") {
1067
+ out += `\\${character}`;
1068
+ continue;
1069
+ }
1070
+ if (atLineStart) {
1071
+ if (character === "#" || character === ">") {
1072
+ out += `\\${character}`;
1073
+ continue;
1074
+ }
1075
+ if ((character === "-" || character === "+") && (value[index + 1] ?? " ") === " ") {
1076
+ out += `\\${character}`;
1077
+ continue;
1078
+ }
1079
+ if (/[0-9]/.test(character)) {
1080
+ let end = index;
1081
+ while (end < value.length && /[0-9]/.test(value[end] ?? "")) end += 1;
1082
+ const marker = value[end];
1083
+ if ((marker === "." || marker === ")") && value[end + 1] === " ") {
1084
+ out += `${value.slice(index, end)}\\${marker}`;
1085
+ index = end;
1086
+ continue;
1087
+ }
1088
+ }
1089
+ }
1090
+ out += character;
1091
+ }
1092
+ return out;
1093
+ }
1094
+ function fenceFor(body, minimum) {
1095
+ let longest = 0;
1096
+ let run = 0;
1097
+ for (const character of body) if (character === "`") {
1098
+ run += 1;
1099
+ longest = Math.max(longest, run);
1100
+ } else run = 0;
1101
+ return "`".repeat(Math.max(minimum, longest + 1));
1102
+ }
1103
+ function renderInline(nodes, depth) {
1104
+ return nodes.map((child) => render(child, depth + 1)).join("");
1105
+ }
1106
+ function renderBlocks(blocks, depth) {
1107
+ return blocks.map((block) => render(block, depth + 1)).join("\n\n");
1108
+ }
1109
+ function renderItem(item, marker, depth) {
1110
+ const body = renderBlocks(item.children, depth + 1);
1111
+ const pad = " ".repeat(marker.length);
1112
+ return body.split("\n").map((line, index) => index === 0 ? marker + line : line === "" ? "" : pad + line).join("\n");
1113
+ }
1114
+ function renderCell(cell, depth) {
1115
+ return renderInline(cell, depth + 1).replace(/\|/g, "\\|");
1116
+ }
1117
+ function renderTable(current, depth) {
1118
+ const columns = current.header.length;
1119
+ return [
1120
+ `| ${current.header.map((cell) => renderCell(cell, depth)).join(" | ")} |`,
1121
+ `| ${current.align.map((align) => {
1122
+ if (align === "left") return ":--";
1123
+ if (align === "right") return "--:";
1124
+ if (align === "center") return ":-:";
1125
+ return "---";
1126
+ }).join(" | ")} |`,
1127
+ ...current.rows.map((row) => {
1128
+ const cells = [];
1129
+ for (let column = 0; column < columns; column += 1) {
1130
+ const cell = row[column];
1131
+ cells.push(cell === void 0 ? "" : renderCell(cell, depth));
1132
+ }
1133
+ return `| ${cells.join(" | ")} |`;
1134
+ })
1135
+ ].join("\n");
1136
+ }
1137
+ function render(current, depth) {
1138
+ if (depth >= 64) return "value" in current && typeof current.value === "string" ? escapeText(current.value) : "";
1139
+ switch (current.element) {
1140
+ case "document": return renderBlocks(current.children, depth);
1141
+ case "heading": {
1142
+ const escaped = renderInline(current.children, depth).replace(/(^|[^\\])(#+)$/, (_match, pre, hashes) => {
1143
+ return `${pre}\\${hashes[0] ?? ""}${hashes.slice(1)}`;
1144
+ });
1145
+ return `${"#".repeat(current.level)} ${escaped}`;
1146
+ }
1147
+ case "paragraph": return renderInline(current.children, depth);
1148
+ case "thematicBreak": return "---";
1149
+ case "blockquote": return renderBlocks(current.children, depth).split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n");
1150
+ case "codeBlock": {
1151
+ const fence = fenceFor(current.code, 3);
1152
+ return `${fence}${current.lang === void 0 ? "" : current.lang}\n${current.code}\n${fence}`;
1153
+ }
1154
+ case "list": {
1155
+ let ordinal = current.start;
1156
+ return current.items.map((item) => {
1157
+ return renderItem(item, current.ordered ? `${ordinal++}. ` : "- ", depth);
1158
+ }).join("\n");
1159
+ }
1160
+ case "listItem": return renderBlocks(current.children, depth);
1161
+ case "table": return renderTable(current, depth);
1162
+ case "text": return escapeText(current.value);
1163
+ case "emphasis": {
1164
+ const marker = current.strong ? "**" : "*";
1165
+ return `${marker}${renderInline(current.children, depth)}${marker}`;
1166
+ }
1167
+ case "codeSpan": {
1168
+ const fence = fenceFor(current.value, 1);
1169
+ const pad = current.value.startsWith("`") || current.value.endsWith("`") ? " " : "";
1170
+ return `${fence}${pad}${current.value}${pad}${fence}`;
1171
+ }
1172
+ case "link": {
1173
+ const href = current.href.replace(/[\\()]/g, (character) => `\\${character}`);
1174
+ return `[${renderInline(current.children, depth)}](${href})`;
1175
+ }
1176
+ default: return "";
1177
+ }
1178
+ }
1179
+ return render(node, 0);
1180
+ }
1181
+ /**
1182
+ * Depth-first, pre-order, root-inclusive traversal of a {@link MarkdownNode} - yields
1183
+ * the node itself, then recurses into its children (block children, list items, table
1184
+ * header/row cells' inline nodes) in walk order.
1185
+ *
1186
+ * @remarks
1187
+ * Total: never throws. Descent stops at {@link MAX_DEPTH} (the node at the cap is
1188
+ * still yielded; its children are not) so pathologically deep input cannot exhaust
1189
+ * the call stack.
1190
+ *
1191
+ * @param node - The AST node to walk (a full document, or any sub-node)
1192
+ * @returns A generator yielding every visited node, pre-order
1193
+ *
1194
+ * @example
1195
+ * ```ts
1196
+ * const doc = { element: 'document', children: [{ element: 'thematicBreak' }] } as const
1197
+ * [...walkNodes(doc)].map((node) => node.element) // ['document', 'thematicBreak']
1198
+ * ```
1199
+ */
1200
+ function* walkNodes(node) {
1201
+ function* walk(current, depth) {
1202
+ yield current;
1203
+ if (depth >= 64) return;
1204
+ switch (current.element) {
1205
+ case "document":
1206
+ case "heading":
1207
+ case "paragraph":
1208
+ case "blockquote":
1209
+ case "listItem":
1210
+ case "emphasis":
1211
+ case "link":
1212
+ for (const child of current.children) yield* walk(child, depth + 1);
1213
+ return;
1214
+ case "list":
1215
+ for (const item of current.items) yield* walk(item, depth + 1);
1216
+ return;
1217
+ case "table":
1218
+ for (const cell of current.header) for (const inline of cell) yield* walk(inline, depth + 1);
1219
+ for (const row of current.rows) for (const cell of row) for (const inline of cell) yield* walk(inline, depth + 1);
1220
+ return;
1221
+ default: return;
1222
+ }
1223
+ }
1224
+ yield* walk(node, 0);
1225
+ }
1226
+ /**
1227
+ * Fold a {@link MarkdownNode} into a `T` via a total catamorphism - children are
1228
+ * folded first (post-order), then the node's own {@link MarkdownHandler} is invoked
1229
+ * with the already-folded children.
1230
+ *
1231
+ * @remarks
1232
+ * **Table contract.** A {@link TableNode} has no single `children` array - its cells
1233
+ * live in `header` (one inline-node list per column) and `rows` (a list of such
1234
+ * rows). The `table` handler receives ONE folded `T` per inline node, flattened in
1235
+ * walk order across ALL cells - every header cell's inline nodes (column order), then
1236
+ * every body row's cells' inline nodes (row order, then column order) - and reads
1237
+ * `node.header[c].length` / `node.rows[r][c].length` off the table node itself to
1238
+ * recover cell boundaries within the flat list.
1239
+ *
1240
+ * Total: never throws. At `depth >= {@link MAX_DEPTH}` the node's handler is invoked
1241
+ * with an empty children list instead of recursing further.
1242
+ *
1243
+ * @param node - The AST node to fold
1244
+ * @param handlers - The total {@link MarkdownHandlers} table, one handler per element
1245
+ * @param depth - The starting recursion depth (pass `0` at the entry point)
1246
+ * @returns The folded `T`
1247
+ *
1248
+ * @example
1249
+ * ```ts
1250
+ * const countHandlers: MarkdownHandlers<number> = {
1251
+ * document: (_, children) => children.reduce((a, b) => a + b, 1),
1252
+ * // ...one handler per element, each summing its folded children
1253
+ * }
1254
+ * foldNode(document, countHandlers, 0) // total node count
1255
+ * ```
1256
+ */
1257
+ function foldNode(node, handlers, depth) {
1258
+ function dispatch(current, children) {
1259
+ switch (current.element) {
1260
+ case "document": return handlers.document(current, children);
1261
+ case "heading": return handlers.heading(current, children);
1262
+ case "paragraph": return handlers.paragraph(current, children);
1263
+ case "thematicBreak": return handlers.thematicBreak(current, children);
1264
+ case "blockquote": return handlers.blockquote(current, children);
1265
+ case "codeBlock": return handlers.codeBlock(current, children);
1266
+ case "list": return handlers.list(current, children);
1267
+ case "listItem": return handlers.listItem(current, children);
1268
+ case "table": return handlers.table(current, children);
1269
+ case "text": return handlers.text(current, children);
1270
+ case "emphasis": return handlers.emphasis(current, children);
1271
+ case "codeSpan": return handlers.codeSpan(current, children);
1272
+ case "link": return handlers.link(current, children);
1273
+ }
1274
+ }
1275
+ function childNodes(current) {
1276
+ switch (current.element) {
1277
+ case "document":
1278
+ case "heading":
1279
+ case "paragraph":
1280
+ case "blockquote":
1281
+ case "listItem":
1282
+ case "emphasis":
1283
+ case "link": return current.children;
1284
+ case "list": return current.items;
1285
+ case "table": {
1286
+ const header = current.header.flatMap((cell) => cell);
1287
+ const rows = current.rows.flatMap((row) => row.flatMap((cell) => cell));
1288
+ return [...header, ...rows];
1289
+ }
1290
+ default: return [];
1291
+ }
1292
+ }
1293
+ function fold(current, level) {
1294
+ if (level >= 64) return dispatch(current, []);
1295
+ return dispatch(current, childNodes(current).map((child) => fold(child, level + 1)));
1296
+ }
1297
+ return fold(node, depth);
1298
+ }
1299
+ /**
1300
+ * Rewrite a {@link MarkdownDocument} bottom-up (copy-on-write) - each node's children
1301
+ * are rewritten first (post-order), then `rewrite` is applied to the node itself; the
1302
+ * document ROOT is never passed to `rewrite` (the `element: 'document'` invariant
1303
+ * always holds). A table's inline cells and a list's items ARE rewritten.
1304
+ *
1305
+ * @remarks
1306
+ * Never mutates `document` - every level is rebuilt into a fresh object/array, even
1307
+ * when `rewrite` returns its input unchanged. When `rewrite` returns a node whose
1308
+ * `element` does not fit the slot it was called for (a block slot handed a
1309
+ * non-{@link BlockNode}, an inline slot handed a non-{@link InlineNode}, a list-item
1310
+ * slot handed a non-`listItem`), the ill-fitting result is discarded and the
1311
+ * freshly-rebuilt (unrewritten-at-this-level) node is kept instead - `rewriteDocument`
1312
+ * stays total and never produces a structurally invalid document.
1313
+ *
1314
+ * Descent is capped at {@link MAX_DEPTH}, the same cap {@link walkNodes} and
1315
+ * {@link foldNode} observe: at `depth >= MAX_DEPTH` the subtree is passed through
1316
+ * UNCHANGED (by reference, not rebuilt, and `rewrite` is not invoked on it) instead of
1317
+ * recursing further, so a pathologically deep adopted document cannot exhaust the
1318
+ * call stack. {@link MarkdownInterface.map} inherits this cap since it delegates here.
1319
+ *
1320
+ * @param document - The document AST to rewrite
1321
+ * @param rewrite - The bottom-up {@link MarkdownRewriteHandler}
1322
+ * @returns A new, rewritten {@link MarkdownDocument}
1323
+ *
1324
+ * @example
1325
+ * ```ts
1326
+ * rewriteDocument(document, (node) =>
1327
+ * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
1328
+ * )
1329
+ * ```
1330
+ */
1331
+ function rewriteDocument(document, rewrite) {
1332
+ function rewriteInline(node, depth) {
1333
+ if (depth >= 64) return node;
1334
+ const rebuilt = rebuildInline(node, depth);
1335
+ const result = rewrite(rebuilt);
1336
+ return isInlineNode(result) ? result : rebuilt;
1337
+ }
1338
+ function rewriteBlock(node, depth) {
1339
+ if (depth >= 64) return node;
1340
+ const rebuilt = rebuildBlock(node, depth);
1341
+ const result = rewrite(rebuilt);
1342
+ return isBlockNode(result) ? result : rebuilt;
1343
+ }
1344
+ function rewriteItem(item, depth) {
1345
+ if (depth >= 64) return item;
1346
+ const rebuilt = {
1347
+ element: "listItem",
1348
+ children: item.children.map((child) => rewriteBlock(child, depth + 1))
1349
+ };
1350
+ const result = rewrite(rebuilt);
1351
+ return result.element === "listItem" ? result : rebuilt;
1352
+ }
1353
+ function rebuildInline(node, depth) {
1354
+ switch (node.element) {
1355
+ case "emphasis": return {
1356
+ ...node,
1357
+ children: node.children.map((child) => rewriteInline(child, depth + 1))
1358
+ };
1359
+ case "link": return {
1360
+ ...node,
1361
+ children: node.children.map((child) => rewriteInline(child, depth + 1))
1362
+ };
1363
+ case "text":
1364
+ case "codeSpan": return node;
1365
+ }
1366
+ }
1367
+ function rebuildBlock(node, depth) {
1368
+ switch (node.element) {
1369
+ case "heading": return {
1370
+ ...node,
1371
+ children: node.children.map((child) => rewriteInline(child, depth + 1))
1372
+ };
1373
+ case "paragraph": return {
1374
+ ...node,
1375
+ children: node.children.map((child) => rewriteInline(child, depth + 1))
1376
+ };
1377
+ case "blockquote": return {
1378
+ ...node,
1379
+ children: node.children.map((child) => rewriteBlock(child, depth + 1))
1380
+ };
1381
+ case "list": return {
1382
+ ...node,
1383
+ items: node.items.map((item) => rewriteItem(item, depth + 1))
1384
+ };
1385
+ case "table": return {
1386
+ ...node,
1387
+ header: node.header.map((cell) => cell.map((inline) => rewriteInline(inline, depth + 1))),
1388
+ rows: node.rows.map((row) => row.map((cell) => cell.map((inline) => rewriteInline(inline, depth + 1))))
1389
+ };
1390
+ case "codeBlock":
1391
+ case "thematicBreak": return node;
1392
+ }
1393
+ }
1394
+ return {
1395
+ element: "document",
1396
+ children: document.children.map((child) => rewriteBlock(child, 0))
1397
+ };
1398
+ }
1399
+ /**
1400
+ * Concatenate the `value` / `code` content of every descendant text / code-span /
1401
+ * code-block node under `node`, in walk order - the plain-text projection of an AST
1402
+ * (search indexing, word counts, a text-only preview).
1403
+ *
1404
+ * @remarks
1405
+ * Total: never throws. Descent stops at {@link MAX_DEPTH} (contributes `''` past the
1406
+ * cap instead of recursing further).
1407
+ *
1408
+ * @param node - The AST node to flatten (a full document, or any sub-node)
1409
+ * @returns The concatenated text content
1410
+ *
1411
+ * @example
1412
+ * ```ts
1413
+ * flattenText({ element: 'paragraph', children: [
1414
+ * { element: 'text', value: 'a ' },
1415
+ * { element: 'codeSpan', value: 'b' },
1416
+ * ] })
1417
+ * // 'a b'
1418
+ * ```
1419
+ */
1420
+ function flattenText(node) {
1421
+ function flatten(current, depth) {
1422
+ if (depth >= 64) return "";
1423
+ switch (current.element) {
1424
+ case "text": return current.value;
1425
+ case "codeSpan": return current.value;
1426
+ case "codeBlock": return current.code;
1427
+ case "document":
1428
+ case "heading":
1429
+ case "paragraph":
1430
+ case "blockquote":
1431
+ case "listItem":
1432
+ case "emphasis":
1433
+ case "link": return current.children.map((child) => flatten(child, depth + 1)).join("");
1434
+ case "list": return current.items.map((item) => flatten(item, depth + 1)).join("");
1435
+ case "table": return current.header.map((cell) => cell.map((inline) => flatten(inline, depth + 1)).join("")).join("") + current.rows.map((row) => row.map((cell) => cell.map((inline) => flatten(inline, depth + 1)).join("")).join("")).join("");
1436
+ case "thematicBreak": return "";
1437
+ default: return "";
1438
+ }
1439
+ }
1440
+ return flatten(node, 0);
1441
+ }
1442
+ //#endregion
1443
+ //#region src/core/parsers.ts
1444
+ /**
1445
+ * Parses a run of markdown lines into a block AST, recursing into nested
1446
+ * blockquotes, list items, and depth-capped degrade paragraphs.
1447
+ *
1448
+ * @param lines - The markdown lines to parse.
1449
+ * @param depth - The current recursion depth (blockquotes/lists increment it).
1450
+ * @returns The parsed block nodes.
1451
+ *
1452
+ * @example
1453
+ * ```ts
1454
+ * parseBlocks(['# Hi'], 0) // [{ element: 'heading', level: 1, children: [...] }]
1455
+ * ```
1456
+ */
1457
+ function parseBlocks(lines, depth) {
1458
+ if (depth >= 64) return lines.length > 0 ? [{
1459
+ element: "paragraph",
1460
+ children: [{
1461
+ element: "text",
1462
+ value: lines.join("\n")
1463
+ }]
1464
+ }] : [];
1465
+ const blocks = [];
1466
+ let index = 0;
1467
+ while (index < lines.length) {
1468
+ const line = lines[index] ?? "";
1469
+ if (isBlankLine(line)) {
1470
+ index += 1;
1471
+ continue;
1472
+ }
1473
+ const fence = extractFence(line);
1474
+ if (fence) {
1475
+ const body = [];
1476
+ index += 1;
1477
+ while (index < lines.length && !isFenceClose(lines[index] ?? "", fence.marker)) {
1478
+ body.push(lines[index] ?? "");
1479
+ index += 1;
1480
+ }
1481
+ index += 1;
1482
+ blocks.push({
1483
+ element: "codeBlock",
1484
+ ...fence.lang === void 0 ? {} : { lang: fence.lang },
1485
+ code: body.join("\n")
1486
+ });
1487
+ continue;
1488
+ }
1489
+ if (isThematicBreak(line)) {
1490
+ blocks.push({ element: "thematicBreak" });
1491
+ index += 1;
1492
+ continue;
1493
+ }
1494
+ const heading = extractHeading(line);
1495
+ if (heading) {
1496
+ blocks.push({
1497
+ element: "heading",
1498
+ level: heading.level,
1499
+ children: parseInline(heading.text)
1500
+ });
1501
+ index += 1;
1502
+ continue;
1503
+ }
1504
+ if (isQuote(line)) {
1505
+ const quoted = [];
1506
+ while (index < lines.length && isQuote(lines[index] ?? "")) {
1507
+ quoted.push(stripQuote(lines[index] ?? ""));
1508
+ index += 1;
1509
+ }
1510
+ blocks.push({
1511
+ element: "blockquote",
1512
+ children: parseBlocks(quoted, depth + 1)
1513
+ });
1514
+ continue;
1515
+ }
1516
+ if (isTableStart(line, lines[index + 1])) {
1517
+ const table = collectTable(lines, index);
1518
+ blocks.push(table.node);
1519
+ index = table.next;
1520
+ continue;
1521
+ }
1522
+ if (extractListItem(line)) {
1523
+ const list = collectList(lines, index, depth);
1524
+ blocks.push(list.node);
1525
+ index = list.next;
1526
+ continue;
1527
+ }
1528
+ const paragraph = [];
1529
+ while (index < lines.length && !isBlankLine(lines[index] ?? "") && !((0, _orkestrel_contract.isNonEmptyArray)(paragraph) && startsBlock(lines, index))) {
1530
+ paragraph.push((lines[index] ?? "").trim());
1531
+ index += 1;
1532
+ }
1533
+ blocks.push({
1534
+ element: "paragraph",
1535
+ children: parseInline(paragraph.join("\n"))
1536
+ });
1537
+ }
1538
+ return blocks;
1539
+ }
1540
+ /**
1541
+ * Collects a GFM table starting at a header row, parsing the header, the
1542
+ * alignment row, and every contiguous body row that follows.
1543
+ *
1544
+ * @param lines - The markdown lines to scan.
1545
+ * @param start - The index of the header row.
1546
+ * @returns The parsed table node and the index of the first line after it.
1547
+ *
1548
+ * @example
1549
+ * ```ts
1550
+ * collectTable(['| a |', '| - |'], 0) // { node: { element: 'table', ... }, next: 2 }
1551
+ * ```
1552
+ */
1553
+ function collectTable(lines, start) {
1554
+ const headerCells = splitTableRow(lines[start] ?? "");
1555
+ const columns = headerCells.length;
1556
+ const header = headerCells.map((cell) => parseInline(cell.trim()));
1557
+ const align = tableAlignments(lines[start + 1] ?? "");
1558
+ const padded = [];
1559
+ for (let column = 0; column < columns; column += 1) padded.push(align[column] ?? "none");
1560
+ const rows = [];
1561
+ let index = start + 2;
1562
+ while (index < lines.length && !isBlankLine(lines[index] ?? "") && (lines[index] ?? "").includes("|")) {
1563
+ const cells = splitTableRow(lines[index] ?? "");
1564
+ const row = [];
1565
+ for (let column = 0; column < columns; column += 1) row.push(parseInline((cells[column] ?? "").trim()));
1566
+ rows.push(row);
1567
+ index += 1;
1568
+ }
1569
+ return {
1570
+ node: {
1571
+ element: "table",
1572
+ header,
1573
+ rows,
1574
+ align: padded
1575
+ },
1576
+ next: index
1577
+ };
1578
+ }
1579
+ /**
1580
+ * Collects a list starting at the first item, gathering sibling items at the
1581
+ * same indent/ordering and recursing into each item's own block content.
1582
+ *
1583
+ * @param lines - The markdown lines to scan.
1584
+ * @param start - The index of the first list item.
1585
+ * @param depth - The current recursion depth (each item recurses at `depth + 1`).
1586
+ * @returns The parsed list node and the index of the first line after it.
1587
+ *
1588
+ * @example
1589
+ * ```ts
1590
+ * collectList(['- item'], 0, 0) // { node: { element: 'list', ... }, next: 1 }
1591
+ * ```
1592
+ */
1593
+ function collectList(lines, start, depth) {
1594
+ const first = extractListItem(lines[start] ?? "");
1595
+ const ordered = first?.ordered ?? false;
1596
+ const startOrdinal = first?.start ?? 1;
1597
+ const topIndent = first?.indent ?? 0;
1598
+ const items = [];
1599
+ let index = start;
1600
+ while (index < lines.length) {
1601
+ const parsed = extractListItem(lines[index] ?? "");
1602
+ if (!parsed || parsed.indent > topIndent || parsed.ordered !== ordered) break;
1603
+ const itemLines = [parsed.content];
1604
+ const continuation = parsed.marker;
1605
+ index += 1;
1606
+ while (index < lines.length) {
1607
+ const next = lines[index] ?? "";
1608
+ if (isBlankLine(next)) {
1609
+ const after = lines[index + 1] ?? "";
1610
+ if (index + 1 < lines.length && !isBlankLine(after) && leadingIndent(after) >= continuation) {
1611
+ itemLines.push("");
1612
+ index += 1;
1613
+ continue;
1614
+ }
1615
+ break;
1616
+ }
1617
+ if (leadingIndent(next) >= continuation) {
1618
+ itemLines.push(next.slice(continuation));
1619
+ index += 1;
1620
+ continue;
1621
+ }
1622
+ if (extractListItem(next) || startsBlock(lines, index)) break;
1623
+ itemLines.push(next.trim());
1624
+ index += 1;
1625
+ }
1626
+ items.push({
1627
+ element: "listItem",
1628
+ children: parseBlocks(itemLines, depth + 1)
1629
+ });
1630
+ }
1631
+ return {
1632
+ node: {
1633
+ element: "list",
1634
+ ordered,
1635
+ start: startOrdinal,
1636
+ items
1637
+ },
1638
+ next: index
1639
+ };
1640
+ }
1641
+ /**
1642
+ * Parses a markdown string into a typed {@link MarkdownDocument} AST via the
1643
+ * block phase.
1644
+ *
1645
+ * @param markdown - The markdown source to parse.
1646
+ * @returns The parsed document.
1647
+ */
1648
+ function parseDocument(markdown) {
1649
+ return {
1650
+ element: "document",
1651
+ children: parseBlocks(splitLines(markdown), 0)
1652
+ };
1653
+ }
1654
+ /**
1655
+ * Parses inline markdown text (emphasis, code spans, links) into inline AST
1656
+ * nodes, coalescing adjacent text runs.
1657
+ *
1658
+ * @param text - The inline markdown text to parse.
1659
+ * @returns The parsed inline nodes.
1660
+ */
1661
+ function parseInline(text) {
1662
+ return coalesceText(scanInline(text, 0, text.length));
1663
+ }
1664
+ //#endregion
1665
+ //#region src/core/shapers.ts
1666
+ /**
1667
+ * The shape of a {@link TextNode} - a plain-text leaf inline run.
1668
+ *
1669
+ * @example
1670
+ * ```ts
1671
+ * import { createContract } from '@orkestrel/contract'
1672
+ * import { textShape } from '@src/core'
1673
+ *
1674
+ * const text = createContract(textShape)
1675
+ * text.is({ element: 'text', value: 'hi' }) // true
1676
+ * ```
1677
+ */
1678
+ var textShape = (0, _orkestrel_contract.objectShape)({
1679
+ element: (0, _orkestrel_contract.literalShape)(["text"]),
1680
+ value: (0, _orkestrel_contract.stringShape)()
1681
+ });
1682
+ /**
1683
+ * The shape of a {@link CodeSpanNode} - an inline code span (`` `code` ``).
1684
+ *
1685
+ * @example
1686
+ * ```ts
1687
+ * import { createContract } from '@orkestrel/contract'
1688
+ * import { codeSpanShape } from '@src/core'
1689
+ *
1690
+ * const codeSpan = createContract(codeSpanShape)
1691
+ * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
1692
+ * ```
1693
+ */
1694
+ var codeSpanShape = (0, _orkestrel_contract.objectShape)({
1695
+ element: (0, _orkestrel_contract.literalShape)(["codeSpan"]),
1696
+ value: (0, _orkestrel_contract.stringShape)()
1697
+ });
1698
+ /**
1699
+ * The shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
1700
+ * optional (absent when the opening fence carries no info-string).
1701
+ *
1702
+ * @example
1703
+ * ```ts
1704
+ * import { createContract } from '@orkestrel/contract'
1705
+ * import { codeBlockShape } from '@src/core'
1706
+ *
1707
+ * const codeBlock = createContract(codeBlockShape)
1708
+ * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
1709
+ * codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
1710
+ * ```
1711
+ */
1712
+ var codeBlockShape = (0, _orkestrel_contract.objectShape)({
1713
+ element: (0, _orkestrel_contract.literalShape)(["codeBlock"]),
1714
+ lang: (0, _orkestrel_contract.optionalShape)((0, _orkestrel_contract.stringShape)()),
1715
+ code: (0, _orkestrel_contract.stringShape)()
1716
+ });
1717
+ /**
1718
+ * The shape of a {@link ThematicBreakNode} - a horizontal rule. Carries no
1719
+ * fields beyond its `element` discriminant.
1720
+ *
1721
+ * @example
1722
+ * ```ts
1723
+ * import { createContract } from '@orkestrel/contract'
1724
+ * import { thematicBreakShape } from '@src/core'
1725
+ *
1726
+ * const thematicBreak = createContract(thematicBreakShape)
1727
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
1728
+ * ```
1729
+ */
1730
+ var thematicBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["thematicBreak"]) });
1731
+ /**
1732
+ * The shape of a {@link TableAlign} - the per-column GFM table alignment
1733
+ * literal.
1734
+ *
1735
+ * @example
1736
+ * ```ts
1737
+ * import { createContract } from '@orkestrel/contract'
1738
+ * import { tableAlignShape } from '@src/core'
1739
+ *
1740
+ * const tableAlign = createContract(tableAlignShape)
1741
+ * tableAlign.is('left') // true
1742
+ * tableAlign.is('center') // true
1743
+ * tableAlign.is('top') // false
1744
+ * ```
1745
+ */
1746
+ var tableAlignShape = (0, _orkestrel_contract.literalShape)([
1747
+ "none",
1748
+ "left",
1749
+ "right",
1750
+ "center"
1751
+ ]);
1752
+ /**
1753
+ * The shape of {@link ListItemParts} - the parsed parts of a single list-item
1754
+ * line the block phase's list detector returns. Fully non-recursive (no
1755
+ * nested node fields), so every field shapes directly.
1756
+ *
1757
+ * @example
1758
+ * ```ts
1759
+ * import { createContract } from '@orkestrel/contract'
1760
+ * import { listItemPartsShape } from '@src/core'
1761
+ *
1762
+ * const listItemParts = createContract(listItemPartsShape)
1763
+ * listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
1764
+ * ```
1765
+ */
1766
+ var listItemPartsShape = (0, _orkestrel_contract.objectShape)({
1767
+ ordered: (0, _orkestrel_contract.booleanShape)(),
1768
+ start: (0, _orkestrel_contract.integerShape)(),
1769
+ content: (0, _orkestrel_contract.stringShape)(),
1770
+ indent: (0, _orkestrel_contract.integerShape)(),
1771
+ marker: (0, _orkestrel_contract.integerShape)()
1772
+ });
1773
+ //#endregion
1774
+ //#region src/core/Markdown.ts
1775
+ /**
1776
+ * A stateful, parsed markdown document - wraps a typed {@link MarkdownDocument} AST
1777
+ * with the query (`find` / `filter` / `reduce` / iteration), rewrite (`map`), fold, and
1778
+ * streaming operations {@link MarkdownInterface} declares.
1779
+ *
1780
+ * @remarks
1781
+ * - **Construction.** Given a `string`, the constructor runs {@link parseDocument} (the
1782
+ * block phase then the inline phase) to build the AST. Given a {@link MarkdownDocument},
1783
+ * the document is adopted AS-IS and is NOT re-validated - a caller adopting an
1784
+ * untrusted value should gate it with `isMarkdownDocument` first.
1785
+ * - **Immutable.** {@link map} never mutates the stored AST - it returns a NEW `Markdown`
1786
+ * instance; the document root invariant (`element: 'document'`) always holds.
1787
+ * - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
1788
+ * on it walk the AST depth-first, pre-order, root-inclusive (via {@link walkNodes});
1789
+ * `stream` is shallow - only the document's direct block children.
1790
+ *
1791
+ * @example
1792
+ * ```ts
1793
+ * import { Markdown, isHeadingNode, renderMarkdown } from '@src/core'
1794
+ *
1795
+ * const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
1796
+ * const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
1797
+ * const shouted = markdown.map((node) =>
1798
+ * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
1799
+ * )
1800
+ * renderMarkdown(shouted.document) // '# TITLE\n\nA **BOLD** [LINK](https://x.dev).'
1801
+ * ```
1802
+ */
1803
+ var Markdown = class Markdown {
1804
+ #document;
1805
+ constructor(input) {
1806
+ this.#document = typeof input === "string" ? parseDocument(input) : input;
1807
+ }
1808
+ /** The stored {@link MarkdownDocument} AST root. */
1809
+ get document() {
1810
+ return this.#document;
1811
+ }
1812
+ /**
1813
+ * THE deep traversal - a lazy, depth-first, pre-order, root-inclusive generator
1814
+ * over every {@link MarkdownNode} in the document. `find` / `filter` / `reduce`
1815
+ * all iterate this single traversal.
1816
+ *
1817
+ * @example
1818
+ * ```ts
1819
+ * for (const node of markdown.walk()) {
1820
+ * // every node, depth-first, pre-order, root-inclusive
1821
+ * }
1822
+ *
1823
+ * // also consumable by for-await - JS accepts a sync iterable in for-await
1824
+ * for await (const node of markdown.walk()) {
1825
+ * // same sequence, no separate async iterator needed
1826
+ * }
1827
+ * ```
1828
+ */
1829
+ *walk() {
1830
+ yield* walkNodes(this.#document);
1831
+ }
1832
+ find(predicate) {
1833
+ for (const node of this.walk()) if (predicate(node)) return node;
1834
+ }
1835
+ filter(predicate) {
1836
+ const out = [];
1837
+ for (const node of this.walk()) if (predicate(node)) out.push(node);
1838
+ return out;
1839
+ }
1840
+ /** Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown}. */
1841
+ map(rewrite) {
1842
+ return new Markdown(rewriteDocument(this.#document, rewrite));
1843
+ }
1844
+ /** Folds the AST depth-first, pre-order into an accumulator. */
1845
+ reduce(callback, initial) {
1846
+ let accumulator = initial;
1847
+ for (const node of this.walk()) accumulator = callback(accumulator, node);
1848
+ return accumulator;
1849
+ }
1850
+ /** Runs a total catamorphism over the document using a {@link MarkdownHandlers} table. */
1851
+ fold(handlers) {
1852
+ return foldNode(this.#document, handlers, 0);
1853
+ }
1854
+ /**
1855
+ * A web-standard {@link ReadableStream} over the document's top-level block nodes
1856
+ * (shallow, source order) - a fresh, pull-based source per call: one block is
1857
+ * enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
1858
+ * async-iterable wherever the platform supports it (Node, Deno), and pipeable
1859
+ * through any {@link TransformStream} / {@link WritableStream}.
1860
+ *
1861
+ * @example
1862
+ * ```ts
1863
+ * // universal - works in every ReadableStream-supporting environment
1864
+ * const reader = markdown.stream().getReader()
1865
+ * for (let result = await reader.read(); !result.done; result = await reader.read()) {
1866
+ * console.log(result.value) // one BlockNode
1867
+ * }
1868
+ *
1869
+ * // Node / Deno / Firefox support async iteration of ReadableStream natively;
1870
+ * // other environments should use the reader loop above instead.
1871
+ * for await (const block of markdown.stream()) {
1872
+ * console.log(block)
1873
+ * }
1874
+ * ```
1875
+ */
1876
+ stream() {
1877
+ const blocks = this.#document.children;
1878
+ let index = 0;
1879
+ return new ReadableStream({ pull(controller) {
1880
+ if (index < blocks.length) {
1881
+ controller.enqueue(blocks[index]);
1882
+ index += 1;
1883
+ } else controller.close();
1884
+ } });
1885
+ }
1886
+ };
1887
+ //#endregion
1888
+ //#region src/core/factories.ts
1889
+ /**
1890
+ * Create a stateful markdown handle from a markdown string or an already-parsed
1891
+ * {@link MarkdownDocument} - a typed AST plus the query, rewrite, and fold operations
1892
+ * {@link MarkdownInterface} exposes.
1893
+ *
1894
+ * @remarks
1895
+ * Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
1896
+ * fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
1897
+ * inline code / links) to build a render-agnostic {@link MarkdownDocument}. Given a
1898
+ * {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
1899
+ * value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
1900
+ * degrades to text, never throws) and zero-dependency - a hand-written scanner, no
1901
+ * regex-only structural parse, linear-time (no ReDoS).
1902
+ *
1903
+ * @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
1904
+ * @returns A working {@link MarkdownInterface}
1905
+ *
1906
+ * @example
1907
+ * ```ts
1908
+ * import { createMarkdown } from '@src/core'
1909
+ *
1910
+ * const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
1911
+ * markdown.document.children[0] // { element: 'heading', ... }
1912
+ * ```
1913
+ */
1914
+ function createMarkdown(input) {
1915
+ return new Markdown(input);
1916
+ }
1917
+ /**
1918
+ * Compile the {@link textShape} into a {@link ContractInterface} for
1919
+ * {@link TextNode} - a guard, coercing parser, JSON Schema, and seeded
1920
+ * generator from one shape declaration (AGENTS §14).
1921
+ *
1922
+ * @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
1923
+ *
1924
+ * @example
1925
+ * ```ts
1926
+ * import { createTextContract } from '@src/core'
1927
+ *
1928
+ * const text = createTextContract()
1929
+ * text.is({ element: 'text', value: 'hi' }) // true
1930
+ * ```
1931
+ */
1932
+ function createTextContract() {
1933
+ return (0, _orkestrel_contract.createContract)(textShape);
1934
+ }
1935
+ /**
1936
+ * Compile the {@link codeSpanShape} into a {@link ContractInterface} for
1937
+ * {@link CodeSpanNode} - a guard, coercing parser, JSON Schema, and seeded
1938
+ * generator from one shape declaration (AGENTS §14).
1939
+ *
1940
+ * @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
1941
+ *
1942
+ * @example
1943
+ * ```ts
1944
+ * import { createCodeSpanContract } from '@src/core'
1945
+ *
1946
+ * const codeSpan = createCodeSpanContract()
1947
+ * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
1948
+ * ```
1949
+ */
1950
+ function createCodeSpanContract() {
1951
+ return (0, _orkestrel_contract.createContract)(codeSpanShape);
1952
+ }
1953
+ /**
1954
+ * Compile the {@link codeBlockShape} into a {@link ContractInterface} for
1955
+ * {@link CodeBlockNode} - a guard, coercing parser, JSON Schema, and seeded
1956
+ * generator from one shape declaration (AGENTS §14).
1957
+ *
1958
+ * @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
1959
+ *
1960
+ * @example
1961
+ * ```ts
1962
+ * import { createCodeBlockContract } from '@src/core'
1963
+ *
1964
+ * const codeBlock = createCodeBlockContract()
1965
+ * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
1966
+ * ```
1967
+ */
1968
+ function createCodeBlockContract() {
1969
+ return (0, _orkestrel_contract.createContract)(codeBlockShape);
1970
+ }
1971
+ /**
1972
+ * Compile the {@link thematicBreakShape} into a {@link ContractInterface} for
1973
+ * {@link ThematicBreakNode} - a guard, coercing parser, JSON Schema, and
1974
+ * seeded generator from one shape declaration (AGENTS §14).
1975
+ *
1976
+ * @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
1977
+ *
1978
+ * @example
1979
+ * ```ts
1980
+ * import { createThematicBreakContract } from '@src/core'
1981
+ *
1982
+ * const thematicBreak = createThematicBreakContract()
1983
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
1984
+ * ```
1985
+ */
1986
+ function createThematicBreakContract() {
1987
+ return (0, _orkestrel_contract.createContract)(thematicBreakShape);
1988
+ }
1989
+ //#endregion
1990
+ exports.MAX_DEPTH = MAX_DEPTH;
1991
+ exports.Markdown = Markdown;
1992
+ exports.SAFE_URL_SCHEMES = SAFE_URL_SCHEMES;
1993
+ exports.coalesceText = coalesceText;
1994
+ exports.codeBlockShape = codeBlockShape;
1995
+ exports.codeSpanShape = codeSpanShape;
1996
+ exports.collectList = collectList;
1997
+ exports.collectTable = collectTable;
1998
+ exports.createCodeBlockContract = createCodeBlockContract;
1999
+ exports.createCodeSpanContract = createCodeSpanContract;
2000
+ exports.createMarkdown = createMarkdown;
2001
+ exports.createTextContract = createTextContract;
2002
+ exports.createThematicBreakContract = createThematicBreakContract;
2003
+ exports.escapeHtml = escapeHtml;
2004
+ exports.extractFence = extractFence;
2005
+ exports.extractHeading = extractHeading;
2006
+ exports.extractListItem = extractListItem;
2007
+ exports.flattenText = flattenText;
2008
+ exports.foldNode = foldNode;
2009
+ exports.isBlankLine = isBlankLine;
2010
+ exports.isBlockNode = isBlockNode;
2011
+ exports.isBlockquoteNode = isBlockquoteNode;
2012
+ exports.isCodeBlockNode = isCodeBlockNode;
2013
+ exports.isCodeSpanNode = isCodeSpanNode;
2014
+ exports.isEmphasisNode = isEmphasisNode;
2015
+ exports.isEscapable = isEscapable;
2016
+ exports.isFenceClose = isFenceClose;
2017
+ exports.isFenceWhitespace = isFenceWhitespace;
2018
+ exports.isHeadingNode = isHeadingNode;
2019
+ exports.isInlineNode = isInlineNode;
2020
+ exports.isLinkNode = isLinkNode;
2021
+ exports.isListNode = isListNode;
2022
+ exports.isMarkdownDocument = isMarkdownDocument;
2023
+ exports.isMarkdownNode = isMarkdownNode;
2024
+ exports.isParagraphNode = isParagraphNode;
2025
+ exports.isQuote = isQuote;
2026
+ exports.isTableNode = isTableNode;
2027
+ exports.isTableStart = isTableStart;
2028
+ exports.isTextNode = isTextNode;
2029
+ exports.isThematicBreak = isThematicBreak;
2030
+ exports.isThematicBreakNode = isThematicBreakNode;
2031
+ exports.isWhitespace = isWhitespace;
2032
+ exports.leadingIndent = leadingIndent;
2033
+ exports.listItemPartsShape = listItemPartsShape;
2034
+ exports.parseBlocks = parseBlocks;
2035
+ exports.parseDocument = parseDocument;
2036
+ exports.parseInline = parseInline;
2037
+ exports.renderHTML = renderHTML;
2038
+ exports.renderMarkdown = renderMarkdown;
2039
+ exports.rewriteDocument = rewriteDocument;
2040
+ exports.sanitizeUrl = sanitizeUrl;
2041
+ exports.scanCode = scanCode;
2042
+ exports.scanEmphasis = scanEmphasis;
2043
+ exports.scanInline = scanInline;
2044
+ exports.scanLink = scanLink;
2045
+ exports.splitLines = splitLines;
2046
+ exports.splitTableRow = splitTableRow;
2047
+ exports.startsBlock = startsBlock;
2048
+ exports.stripQuote = stripQuote;
2049
+ exports.tableAlignShape = tableAlignShape;
2050
+ exports.tableAlignments = tableAlignments;
2051
+ exports.textShape = textShape;
2052
+ exports.thematicBreakShape = thematicBreakShape;
2053
+ exports.unescapeText = unescapeText;
2054
+ exports.walkNodes = walkNodes;
2055
+
2056
+ //# sourceMappingURL=index.cjs.map