@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.
- package/dist/src/core/Markdown.d.ts +1 -1
- package/dist/src/core/factories.d.ts +2 -2
- package/dist/src/core/helpers.d.ts +90 -1
- package/dist/src/core/index.cjs +2056 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +8 -0
- package/dist/src/core/index.js +209 -1051
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/core/parsers.d.ts +16 -1
- package/dist/src/core/shapers.d.ts +20 -19
- package/dist/src/core/validators.d.ts +106 -9
- package/package.json +25 -15
|
@@ -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>&"\'') // '<a>&"''
|
|
929
|
+
* ```
|
|
930
|
+
*/
|
|
931
|
+
function escapeHtml(text) {
|
|
932
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|