@orkestrel/markdown 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/core/index.cjs +1469 -786
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +802 -274
- package/dist/src/core/index.d.ts +802 -274
- package/dist/src/core/index.js +1460 -787
- package/dist/src/core/index.js.map +1 -1
- package/package.json +11 -12
package/dist/src/core/index.cjs
CHANGED
|
@@ -3,17 +3,17 @@ let _orkestrel_contract = require("@orkestrel/contract");
|
|
|
3
3
|
let _orkestrel_html = require("@orkestrel/html");
|
|
4
4
|
//#region src/core/constants.ts
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* `parsers.ts` helpers)
|
|
8
|
-
* (`markdownToHTML`, `
|
|
9
|
-
* `
|
|
6
|
+
* Caps the recursion depth the parse pipeline (`parseDocument` and its
|
|
7
|
+
* `parsers.ts` helpers), the `helpers.ts` traversal / projection functions
|
|
8
|
+
* (`markdownToHTML`, `renderMarkdown`, `walkNodes`, `foldNode`, `rewriteDocument`),
|
|
9
|
+
* and the `compilers.ts` renderer (`renderHTML`) honor before degrading. It bounds blockquote nesting, inline
|
|
10
10
|
* nesting (emphasis / links), and traversal / projection recursion so pathological
|
|
11
11
|
* or hostile input cannot exhaust the call stack. {@link htmlToMarkdown} is the
|
|
12
12
|
* inherited exception: its fold and depth cap belong to `@orkestrel/html`.
|
|
13
13
|
*/
|
|
14
14
|
var MAX_DEPTH = 64;
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
16
|
+
* Holds the frozen empty HTML-to-markdown projection from which projection factories
|
|
17
17
|
* default every absent field.
|
|
18
18
|
*
|
|
19
19
|
* @example
|
|
@@ -32,182 +32,66 @@ var EMPTY_PROJECTION = Object.freeze({
|
|
|
32
32
|
//#endregion
|
|
33
33
|
//#region src/core/validators.ts
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
* emphasis flanking rule's space test.
|
|
35
|
+
* Determines whether a node is a heading block.
|
|
37
36
|
*
|
|
38
|
-
* @param
|
|
39
|
-
* @returns
|
|
40
|
-
*
|
|
41
|
-
* @example
|
|
42
|
-
* ```ts
|
|
43
|
-
* isWhitespace(' ') // true
|
|
44
|
-
* isWhitespace('a') // false
|
|
45
|
-
* ```
|
|
46
|
-
*/
|
|
47
|
-
function isWhitespace(character) {
|
|
48
|
-
return character === " " || character === " " || character === "\n";
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Whether `character` is escapable by a leading backslash - the ASCII punctuation
|
|
52
|
-
* markdown gives meaning to (so `\*` becomes `*` but `\.` stays `\.`).
|
|
53
|
-
*
|
|
54
|
-
* @param character - The single character after a backslash
|
|
55
|
-
* @returns `true` when a backslash before it is an escape
|
|
56
|
-
*
|
|
57
|
-
* @example
|
|
58
|
-
* ```ts
|
|
59
|
-
* isEscapable('*') // true
|
|
60
|
-
* isEscapable('a') // false
|
|
61
|
-
* ```
|
|
62
|
-
*/
|
|
63
|
-
function isEscapable(character) {
|
|
64
|
-
return /[\\`*_{}[\]()#+\-.!>~|]/.test(character);
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Whether `line` is blank - empty, or containing only whitespace - the markdown
|
|
68
|
-
* definition of a blank line that block parsing uses to separate paragraphs, skip
|
|
69
|
-
* gaps, and end list continuations.
|
|
70
|
-
*
|
|
71
|
-
* @param line - The candidate line
|
|
72
|
-
* @returns `true` when the line is blank
|
|
73
|
-
*
|
|
74
|
-
* @example
|
|
75
|
-
* ```ts
|
|
76
|
-
* isBlankLine(' ') // true
|
|
77
|
-
* ```
|
|
78
|
-
*/
|
|
79
|
-
function isBlankLine(line) {
|
|
80
|
-
return (0, _orkestrel_contract.isEmptyString)(line.trim());
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Whether `line` is a blockquote line (`>` optionally indented up to three spaces) -
|
|
84
|
-
* its content is de-quoted by {@link stripQuote}.
|
|
85
|
-
*
|
|
86
|
-
* @param line - The candidate line
|
|
87
|
-
* @returns `true` when the line begins a blockquote
|
|
88
|
-
*
|
|
89
|
-
* @example
|
|
90
|
-
* ```ts
|
|
91
|
-
* isQuote('> quoted') // true
|
|
92
|
-
* ```
|
|
93
|
-
*/
|
|
94
|
-
function isQuote(line) {
|
|
95
|
-
return /^\s{0,3}>/.test(line);
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Whether `line` closes a fence opened by `marker` - the same fence character, a run
|
|
99
|
-
* at least as long, and nothing else but surrounding whitespace.
|
|
100
|
-
*
|
|
101
|
-
* @param line - The candidate closing line
|
|
102
|
-
* @param marker - The opening fence's marker run (from {@link extractFence})
|
|
103
|
-
* @returns `true` when `line` closes the fence
|
|
104
|
-
*
|
|
105
|
-
* @example
|
|
106
|
-
* ```ts
|
|
107
|
-
* isFenceClose('```', '```') // true
|
|
108
|
-
* ```
|
|
109
|
-
*/
|
|
110
|
-
function isFenceClose(line, marker) {
|
|
111
|
-
const character = marker[0] === "~" ? "~" : "`";
|
|
112
|
-
let index = 0;
|
|
113
|
-
while (index < line.length && isFenceWhitespace(line[index])) index++;
|
|
114
|
-
let run = 0;
|
|
115
|
-
while (index < line.length && line[index] === character) {
|
|
116
|
-
run++;
|
|
117
|
-
index++;
|
|
118
|
-
}
|
|
119
|
-
if (run < marker.length) return false;
|
|
120
|
-
while (index < line.length && isFenceWhitespace(line[index])) index++;
|
|
121
|
-
return index === line.length;
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* Whether `character` is a regex-`\s`-equivalent whitespace character - the
|
|
125
|
-
* character class {@link isFenceClose}'s scan treats as surrounding padding.
|
|
126
|
-
*
|
|
127
|
-
* @param character - The single character to test, or `undefined` past the end of a line
|
|
128
|
-
* @returns `true` when it is whitespace
|
|
37
|
+
* @param node - The AST node to test
|
|
38
|
+
* @returns True if the node is a {@link HeadingNode}; false otherwise
|
|
129
39
|
*
|
|
130
40
|
* @example
|
|
131
41
|
* ```ts
|
|
132
|
-
*
|
|
133
|
-
* isFenceWhitespace(undefined) // false
|
|
42
|
+
* isHeadingNode({ element: 'heading', level: 1, children: [] }) // true
|
|
134
43
|
* ```
|
|
135
44
|
*/
|
|
136
|
-
function
|
|
137
|
-
return
|
|
45
|
+
function isHeadingNode(node) {
|
|
46
|
+
return node.element === "heading";
|
|
138
47
|
}
|
|
139
48
|
/**
|
|
140
|
-
*
|
|
141
|
-
* marker `-`, `*`, or `_` (optionally space-separated) and nothing else (`---`,
|
|
142
|
-
* `***`, `___`, `- - -`).
|
|
49
|
+
* Determines whether a node is a paragraph block.
|
|
143
50
|
*
|
|
144
|
-
* @param
|
|
145
|
-
* @returns
|
|
51
|
+
* @param node - The AST node to test
|
|
52
|
+
* @returns True if the node is a {@link ParagraphNode}; false otherwise
|
|
146
53
|
*
|
|
147
54
|
* @example
|
|
148
55
|
* ```ts
|
|
149
|
-
*
|
|
56
|
+
* isParagraphNode({ element: 'paragraph', children: [] }) // true
|
|
150
57
|
* ```
|
|
151
58
|
*/
|
|
152
|
-
function
|
|
153
|
-
|
|
154
|
-
if (stripped.length < 3) return false;
|
|
155
|
-
const marker = stripped[0];
|
|
156
|
-
if (marker !== "-" && marker !== "*" && marker !== "_") return false;
|
|
157
|
-
return [...stripped].every((character) => character === marker);
|
|
59
|
+
function isParagraphNode(node) {
|
|
60
|
+
return node.element === "paragraph";
|
|
158
61
|
}
|
|
159
62
|
/**
|
|
160
|
-
*
|
|
161
|
-
* `|`-separated cells each matching `:?-+:?`, the GFM rule that a table requires a
|
|
162
|
-
* header row IMMEDIATELY followed by a delimiter row.
|
|
63
|
+
* Determines whether a node is a list block.
|
|
163
64
|
*
|
|
164
|
-
* @param
|
|
165
|
-
* @
|
|
166
|
-
* @returns `true` when the two lines open a table
|
|
65
|
+
* @param node - The AST node to test
|
|
66
|
+
* @returns True if the node is a {@link ListNode}; false otherwise
|
|
167
67
|
*
|
|
168
68
|
* @example
|
|
169
69
|
* ```ts
|
|
170
|
-
*
|
|
70
|
+
* isListNode({ element: 'list', ordered: false, start: 1, items: [] }) // true
|
|
171
71
|
* ```
|
|
172
72
|
*/
|
|
173
|
-
function
|
|
174
|
-
|
|
175
|
-
const cells = splitTableRow(delimiter);
|
|
176
|
-
if (cells.length === 0) return false;
|
|
177
|
-
return cells.every((cell) => /^:?-+:?$/.test(cell.trim()));
|
|
178
|
-
}
|
|
179
|
-
/** Determine whether a node is a heading block. */
|
|
180
|
-
function isHeadingNode(node) {
|
|
181
|
-
return node.element === "heading";
|
|
73
|
+
function isListNode(node) {
|
|
74
|
+
return node.element === "list";
|
|
182
75
|
}
|
|
183
76
|
/**
|
|
184
|
-
*
|
|
77
|
+
* Determines whether a node is a GFM table block.
|
|
185
78
|
*
|
|
186
|
-
* @
|
|
187
|
-
*
|
|
188
|
-
* isParagraphNode({ element: 'paragraph', children: [] }) // true
|
|
189
|
-
* ```
|
|
190
|
-
*/
|
|
191
|
-
function isParagraphNode(node) {
|
|
192
|
-
return node.element === "paragraph";
|
|
193
|
-
}
|
|
194
|
-
/**
|
|
195
|
-
* Determine whether a node is a list block.
|
|
79
|
+
* @param node - The AST node to test
|
|
80
|
+
* @returns True if the node is a {@link TableNode}; false otherwise
|
|
196
81
|
*
|
|
197
82
|
* @example
|
|
198
83
|
* ```ts
|
|
199
|
-
*
|
|
84
|
+
* isTableNode({ element: 'table', header: [], rows: [], align: [] }) // true
|
|
200
85
|
* ```
|
|
201
86
|
*/
|
|
202
|
-
function isListNode(node) {
|
|
203
|
-
return node.element === "list";
|
|
204
|
-
}
|
|
205
|
-
/** Determine whether a node is a GFM table block. */
|
|
206
87
|
function isTableNode(node) {
|
|
207
88
|
return node.element === "table";
|
|
208
89
|
}
|
|
209
90
|
/**
|
|
210
|
-
*
|
|
91
|
+
* Determines whether a node is a fenced code block.
|
|
92
|
+
*
|
|
93
|
+
* @param node - The AST node to test
|
|
94
|
+
* @returns True if the node is a {@link CodeBlockNode}; false otherwise
|
|
211
95
|
*
|
|
212
96
|
* @example
|
|
213
97
|
* ```ts
|
|
@@ -218,7 +102,10 @@ function isCodeBlockNode(node) {
|
|
|
218
102
|
return node.element === "codeBlock";
|
|
219
103
|
}
|
|
220
104
|
/**
|
|
221
|
-
*
|
|
105
|
+
* Determines whether a node is a blockquote block.
|
|
106
|
+
*
|
|
107
|
+
* @param node - The AST node to test
|
|
108
|
+
* @returns True if the node is a {@link BlockquoteNode}; false otherwise
|
|
222
109
|
*
|
|
223
110
|
* @example
|
|
224
111
|
* ```ts
|
|
@@ -229,7 +116,10 @@ function isBlockquoteNode(node) {
|
|
|
229
116
|
return node.element === "blockquote";
|
|
230
117
|
}
|
|
231
118
|
/**
|
|
232
|
-
*
|
|
119
|
+
* Determines whether a node is a thematic break (horizontal rule) block.
|
|
120
|
+
*
|
|
121
|
+
* @param node - The AST node to test
|
|
122
|
+
* @returns True if the node is a {@link ThematicBreakNode}; false otherwise
|
|
233
123
|
*
|
|
234
124
|
* @example
|
|
235
125
|
* ```ts
|
|
@@ -240,7 +130,10 @@ function isThematicBreakNode(node) {
|
|
|
240
130
|
return node.element === "thematicBreak";
|
|
241
131
|
}
|
|
242
132
|
/**
|
|
243
|
-
*
|
|
133
|
+
* Determines whether a node is a plain text run.
|
|
134
|
+
*
|
|
135
|
+
* @param node - The AST node to test
|
|
136
|
+
* @returns True if the node is a {@link TextNode}; false otherwise
|
|
244
137
|
*
|
|
245
138
|
* @example
|
|
246
139
|
* ```ts
|
|
@@ -251,7 +144,10 @@ function isTextNode(node) {
|
|
|
251
144
|
return node.element === "text";
|
|
252
145
|
}
|
|
253
146
|
/**
|
|
254
|
-
*
|
|
147
|
+
* Determines whether a node is an emphasis run (`*em*` / `**strong**`).
|
|
148
|
+
*
|
|
149
|
+
* @param node - The AST node to test
|
|
150
|
+
* @returns True if the node is an {@link EmphasisNode}; false otherwise
|
|
255
151
|
*
|
|
256
152
|
* @example
|
|
257
153
|
* ```ts
|
|
@@ -262,12 +158,15 @@ function isEmphasisNode(node) {
|
|
|
262
158
|
return node.element === "emphasis";
|
|
263
159
|
}
|
|
264
160
|
/**
|
|
265
|
-
*
|
|
161
|
+
* Determines whether a node is an inline code span.
|
|
266
162
|
*
|
|
267
163
|
* @remarks
|
|
268
164
|
* Narrows to {@link CodeSpanNode} - the node whose `element` discriminant is
|
|
269
165
|
* `'codeSpan'`.
|
|
270
166
|
*
|
|
167
|
+
* @param node - The AST node to test
|
|
168
|
+
* @returns True if the node is a {@link CodeSpanNode}; false otherwise
|
|
169
|
+
*
|
|
271
170
|
* @example
|
|
272
171
|
* ```ts
|
|
273
172
|
* isCodeSpanNode({ element: 'codeSpan', value: 'x' }) // true
|
|
@@ -277,7 +176,10 @@ function isCodeSpanNode(node) {
|
|
|
277
176
|
return node.element === "codeSpan";
|
|
278
177
|
}
|
|
279
178
|
/**
|
|
280
|
-
*
|
|
179
|
+
* Determines whether a node is a GFM hard line break.
|
|
180
|
+
*
|
|
181
|
+
* @param node - The AST node to test
|
|
182
|
+
* @returns True if the node is a {@link LineBreakNode}; false otherwise
|
|
281
183
|
*
|
|
282
184
|
* @example
|
|
283
185
|
* ```ts
|
|
@@ -287,12 +189,25 @@ function isCodeSpanNode(node) {
|
|
|
287
189
|
function isLineBreakNode(node) {
|
|
288
190
|
return node.element === "break";
|
|
289
191
|
}
|
|
290
|
-
/**
|
|
192
|
+
/**
|
|
193
|
+
* Determines whether a node is a link.
|
|
194
|
+
*
|
|
195
|
+
* @param node - The AST node to test
|
|
196
|
+
* @returns True if the node is a {@link LinkNode}; false otherwise
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```ts
|
|
200
|
+
* isLinkNode({ element: 'link', href: 'https://example.dev', children: [] }) // true
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
291
203
|
function isLinkNode(node) {
|
|
292
204
|
return node.element === "link";
|
|
293
205
|
}
|
|
294
206
|
/**
|
|
295
|
-
*
|
|
207
|
+
* Determines whether a node is an image.
|
|
208
|
+
*
|
|
209
|
+
* @param node - The AST node to test
|
|
210
|
+
* @returns True if the node is an {@link ImageNode}; false otherwise
|
|
296
211
|
*
|
|
297
212
|
* @example
|
|
298
213
|
* ```ts
|
|
@@ -303,16 +218,16 @@ function isImageNode(node) {
|
|
|
303
218
|
return node.element === "image";
|
|
304
219
|
}
|
|
305
220
|
/**
|
|
306
|
-
*
|
|
221
|
+
* Determines whether an arbitrary value is a valid {@link InlineNode} - a text
|
|
307
222
|
* run, emphasis, code span, hard break, link, or image, recursively validated.
|
|
308
223
|
*
|
|
309
224
|
* @remarks
|
|
310
225
|
* Total: never throws, even on cyclic or pathologically deep input - every
|
|
311
226
|
* combinator involved (`unionOf`, `recordOf`, `arrayOf`, `lazyOf`) is
|
|
312
|
-
* throw-contained per the `@orkestrel/contract` guard contract
|
|
227
|
+
* throw-contained per the `@orkestrel/contract` guard contract.
|
|
313
228
|
*
|
|
314
229
|
* @param value - The value to test
|
|
315
|
-
* @returns
|
|
230
|
+
* @returns True if `value` is a well-formed {@link InlineNode}; false otherwise
|
|
316
231
|
*
|
|
317
232
|
* @example
|
|
318
233
|
* ```ts
|
|
@@ -342,19 +257,19 @@ var isInlineNode = (0, _orkestrel_contract.unionOf)((0, _orkestrel_contract.reco
|
|
|
342
257
|
children: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.lazyOf)(() => isInlineNode))
|
|
343
258
|
}));
|
|
344
259
|
/**
|
|
345
|
-
*
|
|
260
|
+
* Determines whether an arbitrary value is a valid {@link BlockNode} - a
|
|
346
261
|
* heading, paragraph, list, table, code block, blockquote, or thematic break,
|
|
347
262
|
* recursively validated.
|
|
348
263
|
*
|
|
349
264
|
* @remarks
|
|
350
265
|
* Total: never throws, even on cyclic or pathologically deep input - every
|
|
351
266
|
* combinator involved (`unionOf`, `recordOf`, `arrayOf`, `lazyOf`) is
|
|
352
|
-
* throw-contained per the `@orkestrel/contract` guard contract
|
|
267
|
+
* throw-contained per the `@orkestrel/contract` guard contract.
|
|
353
268
|
* A list item's shape is inlined here (and in {@link isMarkdownNode}) rather
|
|
354
269
|
* than named separately - it is used at exactly these two sites.
|
|
355
270
|
*
|
|
356
271
|
* @param value - The value to test
|
|
357
|
-
* @returns
|
|
272
|
+
* @returns True if `value` is a well-formed {@link BlockNode}; false otherwise
|
|
358
273
|
*
|
|
359
274
|
* @example
|
|
360
275
|
* ```ts
|
|
@@ -393,19 +308,19 @@ var isBlockNode = (0, _orkestrel_contract.unionOf)((0, _orkestrel_contract.recor
|
|
|
393
308
|
children: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.lazyOf)(() => isBlockNode))
|
|
394
309
|
}), (0, _orkestrel_contract.recordOf)({ element: (0, _orkestrel_contract.literalOf)("thematicBreak") }));
|
|
395
310
|
/**
|
|
396
|
-
*
|
|
311
|
+
* Determines whether an arbitrary value is a valid {@link MarkdownNode} - the
|
|
397
312
|
* {@link MarkdownDocument} root, a {@link BlockNode}, a {@link ListItemNode}, or
|
|
398
313
|
* an {@link InlineNode}, recursively validated.
|
|
399
314
|
*
|
|
400
315
|
* @remarks
|
|
401
316
|
* Total: never throws, even on cyclic or pathologically deep input - every
|
|
402
317
|
* combinator involved (`unionOf`, `recordOf`, `arrayOf`, `lazyOf`) is
|
|
403
|
-
* throw-contained per the `@orkestrel/contract` guard contract
|
|
318
|
+
* throw-contained per the `@orkestrel/contract` guard contract.
|
|
404
319
|
* A list item's shape is inlined here (and in {@link isBlockNode}) rather than
|
|
405
320
|
* named separately - it is used at exactly these two sites.
|
|
406
321
|
*
|
|
407
322
|
* @param value - The value to test
|
|
408
|
-
* @returns
|
|
323
|
+
* @returns True if `value` is a well-formed {@link MarkdownNode}; false otherwise
|
|
409
324
|
*
|
|
410
325
|
* @example
|
|
411
326
|
* ```ts
|
|
@@ -420,17 +335,17 @@ var isMarkdownNode = (0, _orkestrel_contract.unionOf)((0, _orkestrel_contract.la
|
|
|
420
335
|
children: (0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.lazyOf)(() => isBlockNode))
|
|
421
336
|
}), (0, _orkestrel_contract.lazyOf)(() => isInlineNode));
|
|
422
337
|
/**
|
|
423
|
-
*
|
|
338
|
+
* Determines whether an arbitrary value is a valid {@link MarkdownDocument} -
|
|
424
339
|
* the parsed-AST root {@link parseDocument} returns, recursively
|
|
425
340
|
* validated.
|
|
426
341
|
*
|
|
427
342
|
* @remarks
|
|
428
343
|
* Total: never throws, even on cyclic or pathologically deep input - every
|
|
429
344
|
* combinator involved (`recordOf`, `arrayOf`) is throw-contained per the
|
|
430
|
-
* `@orkestrel/contract` guard contract
|
|
345
|
+
* `@orkestrel/contract` guard contract.
|
|
431
346
|
*
|
|
432
347
|
* @param value - The value to test
|
|
433
|
-
* @returns
|
|
348
|
+
* @returns True if `value` is a well-formed {@link MarkdownDocument}; false otherwise
|
|
434
349
|
*
|
|
435
350
|
* @example
|
|
436
351
|
* ```ts
|
|
@@ -452,109 +367,188 @@ var isMarkdownDocument = (0, _orkestrel_contract.recordOf)({
|
|
|
452
367
|
*
|
|
453
368
|
* @param lines - The markdown lines to parse.
|
|
454
369
|
* @param depth - The current recursion depth (blockquotes/lists increment it).
|
|
370
|
+
* @param spans - The optional operation-owned node span recorder.
|
|
371
|
+
* @param end - The original-source end of this line run, including a removed terminator.
|
|
455
372
|
* @returns The parsed block nodes.
|
|
456
373
|
*
|
|
457
374
|
* @example
|
|
458
375
|
* ```ts
|
|
459
|
-
* parseBlocks(
|
|
376
|
+
* parseBlocks(splitLines('# Hi'), 0) // [{ element: 'heading', level: 1, children: [...] }]
|
|
460
377
|
* ```
|
|
461
378
|
*/
|
|
462
|
-
function parseBlocks(lines, depth) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
379
|
+
function parseBlocks(lines, depth, spans = /* @__PURE__ */ new Map(), end) {
|
|
380
|
+
const text = lines.map((line) => line.text);
|
|
381
|
+
if (depth >= 64) {
|
|
382
|
+
if (lines.length === 0) return [];
|
|
383
|
+
const source = joinSources(lines, "\n");
|
|
384
|
+
const inline = {
|
|
466
385
|
element: "text",
|
|
467
|
-
value:
|
|
468
|
-
}
|
|
469
|
-
|
|
386
|
+
value: source.text
|
|
387
|
+
};
|
|
388
|
+
const paragraph = {
|
|
389
|
+
element: "paragraph",
|
|
390
|
+
children: [inline]
|
|
391
|
+
};
|
|
392
|
+
const span = projectSpan(source, 0, source.text.length);
|
|
393
|
+
if (span !== void 0) {
|
|
394
|
+
spans.set(inline, span);
|
|
395
|
+
spans.set(paragraph, span);
|
|
396
|
+
}
|
|
397
|
+
return [paragraph];
|
|
398
|
+
}
|
|
470
399
|
const blocks = [];
|
|
471
400
|
let index = 0;
|
|
472
401
|
while (index < lines.length) {
|
|
473
|
-
const line =
|
|
402
|
+
const line = text[index] ?? "";
|
|
474
403
|
if (isBlankLine(line)) {
|
|
475
404
|
index += 1;
|
|
476
405
|
continue;
|
|
477
406
|
}
|
|
478
407
|
const fence = extractFence(line);
|
|
479
408
|
if (fence) {
|
|
409
|
+
const start = index;
|
|
480
410
|
const body = [];
|
|
411
|
+
let closed = false;
|
|
481
412
|
index += 1;
|
|
482
|
-
while (index < lines.length && !isFenceClose(
|
|
483
|
-
|
|
413
|
+
while (index < lines.length && !isFenceClose(text[index] ?? "", fence.marker)) {
|
|
414
|
+
const bodyLine = lines[index];
|
|
415
|
+
if (bodyLine !== void 0) body.push(bodyLine);
|
|
484
416
|
index += 1;
|
|
485
417
|
}
|
|
486
|
-
index
|
|
487
|
-
|
|
418
|
+
if (index < lines.length) {
|
|
419
|
+
closed = true;
|
|
420
|
+
index += 1;
|
|
421
|
+
}
|
|
422
|
+
const node = {
|
|
488
423
|
element: "codeBlock",
|
|
489
424
|
...fence.lang === void 0 ? {} : { lang: fence.lang },
|
|
490
|
-
code: body
|
|
491
|
-
}
|
|
425
|
+
code: joinSources(body, "\n").text
|
|
426
|
+
};
|
|
427
|
+
const source = joinSources(lines.slice(start, index), "\n");
|
|
428
|
+
const span = projectSpan(source, 0, source.text.length);
|
|
429
|
+
if (span !== void 0) spans.set(node, !closed && end !== void 0 ? {
|
|
430
|
+
start: span.start,
|
|
431
|
+
end
|
|
432
|
+
} : span);
|
|
433
|
+
blocks.push(node);
|
|
492
434
|
continue;
|
|
493
435
|
}
|
|
494
436
|
if (isThematicBreak(line)) {
|
|
495
|
-
|
|
437
|
+
const node = { element: "thematicBreak" };
|
|
438
|
+
const source = lines[index];
|
|
439
|
+
const span = source === void 0 ? void 0 : projectSpan(source, 0, source.text.length);
|
|
440
|
+
if (span !== void 0) spans.set(node, span);
|
|
441
|
+
blocks.push(node);
|
|
496
442
|
index += 1;
|
|
497
443
|
continue;
|
|
498
444
|
}
|
|
499
445
|
const heading = extractHeading(line);
|
|
500
446
|
if (heading) {
|
|
501
|
-
|
|
447
|
+
const source = lines[index];
|
|
448
|
+
const content = source === void 0 ? {
|
|
449
|
+
text: heading.text,
|
|
450
|
+
segments: []
|
|
451
|
+
} : sliceSource(source, heading.offset, heading.offset + heading.text.length);
|
|
452
|
+
const node = {
|
|
502
453
|
element: "heading",
|
|
503
454
|
level: heading.level,
|
|
504
|
-
children:
|
|
505
|
-
}
|
|
455
|
+
children: coalesceText(scanInlineSource(content, 0, content.text.length, spans), spans)
|
|
456
|
+
};
|
|
457
|
+
const span = source === void 0 ? void 0 : projectSpan(source, 0, source.text.length);
|
|
458
|
+
if (span !== void 0) spans.set(node, span);
|
|
459
|
+
blocks.push(node);
|
|
506
460
|
index += 1;
|
|
507
461
|
continue;
|
|
508
462
|
}
|
|
509
463
|
if (isQuote(line)) {
|
|
464
|
+
const start = index;
|
|
510
465
|
const quoted = [];
|
|
511
|
-
while (index < lines.length && isQuote(
|
|
512
|
-
|
|
466
|
+
while (index < lines.length && isQuote(text[index] ?? "")) {
|
|
467
|
+
const quotedLine = lines[index];
|
|
468
|
+
if (quotedLine === void 0) break;
|
|
469
|
+
quoted.push(stripQuote(quotedLine));
|
|
513
470
|
index += 1;
|
|
514
471
|
}
|
|
515
|
-
|
|
472
|
+
const source = joinSources(lines.slice(start, index), "\n");
|
|
473
|
+
const span = projectSpan(source, 0, source.text.length);
|
|
474
|
+
const node = {
|
|
516
475
|
element: "blockquote",
|
|
517
|
-
children: parseBlocks(quoted, depth + 1)
|
|
518
|
-
}
|
|
476
|
+
children: parseBlocks(quoted, depth + 1, spans, index === lines.length && end !== void 0 ? end : span?.end)
|
|
477
|
+
};
|
|
478
|
+
if (span !== void 0) spans.set(node, span);
|
|
479
|
+
blocks.push(node);
|
|
519
480
|
continue;
|
|
520
481
|
}
|
|
521
|
-
if (isTableStart(line,
|
|
522
|
-
const table = collectTable(lines, index);
|
|
482
|
+
if (isTableStart(line, text[index + 1])) {
|
|
483
|
+
const table = collectTable(lines, index, spans);
|
|
523
484
|
blocks.push(table.node);
|
|
524
485
|
index = table.next;
|
|
525
486
|
continue;
|
|
526
487
|
}
|
|
527
488
|
if (extractListItem(line)) {
|
|
528
|
-
const list = collectList(lines, index, depth);
|
|
489
|
+
const list = collectList(lines, index, depth, spans, end);
|
|
529
490
|
blocks.push(list.node);
|
|
530
491
|
index = list.next;
|
|
531
492
|
continue;
|
|
532
493
|
}
|
|
494
|
+
const start = index;
|
|
533
495
|
const paragraph = [];
|
|
534
|
-
while (index < lines.length && !isBlankLine(
|
|
535
|
-
|
|
496
|
+
while (index < lines.length && !isBlankLine(text[index] ?? "") && !((0, _orkestrel_contract.isNonEmptyArray)(paragraph) && startsBlock(text, index))) {
|
|
497
|
+
const paragraphLine = lines[index];
|
|
498
|
+
if (paragraphLine !== void 0) paragraph.push(paragraphLine);
|
|
536
499
|
index += 1;
|
|
537
500
|
}
|
|
538
|
-
const source = paragraph.map((paragraphLine, position) => position < paragraph.length - 1
|
|
539
|
-
|
|
501
|
+
const source = joinSources(paragraph.map((paragraphLine, position) => normalizeParagraphLine(paragraphLine, position < paragraph.length - 1)), "\n");
|
|
502
|
+
const node = {
|
|
540
503
|
element: "paragraph",
|
|
541
|
-
children:
|
|
542
|
-
}
|
|
504
|
+
children: coalesceText(scanInlineSource(source, 0, source.text.length, spans), spans)
|
|
505
|
+
};
|
|
506
|
+
const region = joinSources(lines.slice(start, index), "\n");
|
|
507
|
+
const span = projectSpan(region, 0, region.text.length);
|
|
508
|
+
if (span !== void 0) spans.set(node, span);
|
|
509
|
+
blocks.push(node);
|
|
543
510
|
}
|
|
544
511
|
return blocks;
|
|
545
512
|
}
|
|
546
513
|
/**
|
|
547
|
-
* Parses a markdown string into a typed {@link MarkdownDocument} AST
|
|
514
|
+
* Parses a markdown string into a typed {@link MarkdownDocument} AST through the
|
|
548
515
|
* block phase.
|
|
549
516
|
*
|
|
550
517
|
* @param markdown - The markdown source to parse.
|
|
551
518
|
* @returns The parsed document.
|
|
519
|
+
*
|
|
520
|
+
* @example
|
|
521
|
+
* ```ts
|
|
522
|
+
* parseDocument('# Hi') // { element: 'document', children: [{ element: 'heading', ... }] }
|
|
523
|
+
* ```
|
|
552
524
|
*/
|
|
553
525
|
function parseDocument(markdown) {
|
|
554
|
-
|
|
526
|
+
const [document] = parseProvenance(markdown);
|
|
527
|
+
return document;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Parses a markdown string into a document and its original-source spans.
|
|
531
|
+
*
|
|
532
|
+
* @param markdown - The markdown source to parse.
|
|
533
|
+
* @returns The parsed document and its node-identity span map.
|
|
534
|
+
*
|
|
535
|
+
* @example
|
|
536
|
+
* ```ts
|
|
537
|
+
* const [document, spans] = parseProvenance('# Hi')
|
|
538
|
+
* spans.get(document) // { start: 0, end: 4 }
|
|
539
|
+
* ```
|
|
540
|
+
*/
|
|
541
|
+
function parseProvenance(markdown) {
|
|
542
|
+
const spans = /* @__PURE__ */ new Map();
|
|
543
|
+
const document = {
|
|
555
544
|
element: "document",
|
|
556
|
-
children: parseBlocks(splitLines(markdown), 0)
|
|
545
|
+
children: parseBlocks(splitLines(markdown), 0, spans, markdown.length)
|
|
557
546
|
};
|
|
547
|
+
spans.set(document, {
|
|
548
|
+
start: 0,
|
|
549
|
+
end: markdown.length
|
|
550
|
+
});
|
|
551
|
+
return [document, spans];
|
|
558
552
|
}
|
|
559
553
|
/**
|
|
560
554
|
* Parses inline markdown text (emphasis, code spans, links, images, and hard
|
|
@@ -562,462 +556,426 @@ function parseDocument(markdown) {
|
|
|
562
556
|
*
|
|
563
557
|
* @param text - The inline markdown text to parse.
|
|
564
558
|
* @returns The parsed inline nodes.
|
|
559
|
+
*
|
|
560
|
+
* @example
|
|
561
|
+
* ```ts
|
|
562
|
+
* parseInline('a *b*') // [{ element: 'text', value: 'a ' }, { element: 'emphasis', ... }]
|
|
563
|
+
* ```
|
|
565
564
|
*/
|
|
566
565
|
function parseInline(text) {
|
|
567
566
|
return coalesceText(scanInline(text, 0, text.length));
|
|
568
567
|
}
|
|
569
568
|
//#endregion
|
|
570
|
-
//#region src/core/
|
|
569
|
+
//#region src/core/helpers.ts
|
|
571
570
|
/**
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
571
|
+
* Splits a markdown document into offset-bearing lines while normalizing CRLF and
|
|
572
|
+
* bare CR terminators at the line boundary. A single trailing terminator does not
|
|
573
|
+
* yield a final empty line.
|
|
575
574
|
*
|
|
576
|
-
* @
|
|
577
|
-
*
|
|
578
|
-
* block phase then the inline phase) to build the AST. Given a {@link MarkdownDocument},
|
|
579
|
-
* the document is adopted AS-IS and is NOT re-validated - a caller adopting an
|
|
580
|
-
* untrusted value should gate it with `isMarkdownDocument` first.
|
|
581
|
-
* - **Immutable.** {@link map} never mutates the stored AST - it returns a NEW `Markdown`
|
|
582
|
-
* instance; the document root invariant (`element: 'document'`) always holds.
|
|
583
|
-
* - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
|
|
584
|
-
* on it walk the AST depth-first, pre-order, root-inclusive (via {@link walkNodes});
|
|
585
|
-
* `stream` is shallow - only the document's direct block children.
|
|
575
|
+
* @param markdown - The raw markdown source
|
|
576
|
+
* @returns The document's lines with their original-string coordinates
|
|
586
577
|
*
|
|
587
578
|
* @example
|
|
588
579
|
* ```ts
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
* const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
|
|
592
|
-
* const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
|
|
593
|
-
* const shouted = markdown.map((node) =>
|
|
594
|
-
* node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
|
|
595
|
-
* )
|
|
596
|
-
* renderMarkdown(shouted.document) // '# TITLE\n\nA **BOLD** [LINK](https://x.dev).'
|
|
580
|
+
* splitLines('a\r\nb') // [{ text: 'a', segments: [{ offset: 0, start: 0, end: 1 }] }, ...]
|
|
597
581
|
* ```
|
|
598
582
|
*/
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
583
|
+
function splitLines(markdown) {
|
|
584
|
+
const lines = [];
|
|
585
|
+
let start = 0;
|
|
586
|
+
let index = 0;
|
|
587
|
+
while (index < markdown.length) {
|
|
588
|
+
const character = markdown[index];
|
|
589
|
+
if (character !== "\r" && character !== "\n") {
|
|
590
|
+
index += 1;
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
lines.push({
|
|
594
|
+
text: markdown.slice(start, index),
|
|
595
|
+
segments: [{
|
|
596
|
+
offset: 0,
|
|
597
|
+
start,
|
|
598
|
+
end: index
|
|
599
|
+
}]
|
|
600
|
+
});
|
|
601
|
+
index += character === "\r" && markdown[index + 1] === "\n" ? 2 : 1;
|
|
602
|
+
start = index;
|
|
607
603
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
604
|
+
lines.push({
|
|
605
|
+
text: markdown.slice(start),
|
|
606
|
+
segments: [{
|
|
607
|
+
offset: 0,
|
|
608
|
+
start,
|
|
609
|
+
end: markdown.length
|
|
610
|
+
}]
|
|
611
|
+
});
|
|
612
|
+
if (lines.length > 1 && lines[lines.length - 1]?.text === "") lines.pop();
|
|
613
|
+
return lines;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Slices derived markdown text and narrows each intersecting source segment to the
|
|
617
|
+
* same text-relative range.
|
|
618
|
+
*
|
|
619
|
+
* @param source - The offset-bearing source to slice
|
|
620
|
+
* @param from - The inclusive text offset
|
|
621
|
+
* @param to - The exclusive text offset
|
|
622
|
+
* @returns The sliced text and its narrowed original-string segments
|
|
623
|
+
*
|
|
624
|
+
* @example
|
|
625
|
+
* ```ts
|
|
626
|
+
* sliceSource({ text: 'abc', segments: [{ offset: 0, start: 4, end: 7 }] }, 1, 3)
|
|
627
|
+
* // { text: 'bc', segments: [{ offset: 0, start: 5, end: 7 }] }
|
|
628
|
+
* ```
|
|
629
|
+
*/
|
|
630
|
+
function sliceSource(source, from, to) {
|
|
631
|
+
const start = Math.max(0, Math.min(from, source.text.length));
|
|
632
|
+
const end = Math.max(start, Math.min(to, source.text.length));
|
|
633
|
+
const segments = [];
|
|
634
|
+
for (let index = 0; index < source.segments.length; index += 1) {
|
|
635
|
+
const segment = source.segments[index];
|
|
636
|
+
if (segment === void 0) continue;
|
|
637
|
+
const next = source.segments[index + 1];
|
|
638
|
+
const limit = Math.min(segment.offset + (segment.end - segment.start), next === void 0 ? source.text.length : next.offset);
|
|
639
|
+
const overlapStart = Math.max(start, segment.offset);
|
|
640
|
+
const overlapEnd = Math.min(end, limit);
|
|
641
|
+
const empty = segment.offset === limit && overlapStart === segment.offset;
|
|
642
|
+
if (overlapStart >= overlapEnd && !empty) continue;
|
|
643
|
+
const originalStart = overlapStart === limit ? segment.end : Math.min(segment.end, segment.start + overlapStart - segment.offset);
|
|
644
|
+
const originalEnd = overlapEnd === limit ? segment.end : Math.min(segment.end, segment.start + overlapEnd - segment.offset);
|
|
645
|
+
segments.push({
|
|
646
|
+
offset: overlapStart - start,
|
|
647
|
+
start: originalStart,
|
|
648
|
+
end: originalEnd
|
|
649
|
+
});
|
|
627
650
|
}
|
|
628
|
-
|
|
629
|
-
|
|
651
|
+
return {
|
|
652
|
+
text: source.text.slice(start, end),
|
|
653
|
+
segments
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Joins offset-bearing markdown sources while mapping a separator to the original
|
|
658
|
+
* region between adjacent mapped sources.
|
|
659
|
+
*
|
|
660
|
+
* @param sources - The sources to join
|
|
661
|
+
* @param separator - The derived text inserted between sources
|
|
662
|
+
* @returns The joined text and every source-backed segment
|
|
663
|
+
*
|
|
664
|
+
* @example
|
|
665
|
+
* ```ts
|
|
666
|
+
* joinSources(splitLines('a\nb'), '\n')
|
|
667
|
+
* // { text: 'a\nb', segments: [...] }
|
|
668
|
+
* ```
|
|
669
|
+
*/
|
|
670
|
+
function joinSources(sources, separator) {
|
|
671
|
+
let text = "";
|
|
672
|
+
const segments = [];
|
|
673
|
+
for (let index = 0; index < sources.length; index += 1) {
|
|
674
|
+
const source = sources[index];
|
|
675
|
+
if (source === void 0) continue;
|
|
676
|
+
if (index > 0) {
|
|
677
|
+
const previous = sources[index - 1];
|
|
678
|
+
const left = previous?.segments[previous.segments.length - 1];
|
|
679
|
+
const right = source.segments[0];
|
|
680
|
+
if (separator.length > 0 && left !== void 0 && right !== void 0 && left.end < right.start) segments.push({
|
|
681
|
+
offset: text.length,
|
|
682
|
+
start: left.end,
|
|
683
|
+
end: right.start
|
|
684
|
+
});
|
|
685
|
+
text += separator;
|
|
686
|
+
}
|
|
687
|
+
for (const segment of source.segments) segments.push({
|
|
688
|
+
offset: text.length + segment.offset,
|
|
689
|
+
start: segment.start,
|
|
690
|
+
end: segment.end
|
|
691
|
+
});
|
|
692
|
+
text += source.text;
|
|
630
693
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
/** Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown}. */
|
|
637
|
-
map(rewrite) {
|
|
638
|
-
return new Markdown(rewriteDocument(this.#document, rewrite));
|
|
639
|
-
}
|
|
640
|
-
/** Folds the AST depth-first, pre-order into an accumulator. */
|
|
641
|
-
reduce(callback, initial) {
|
|
642
|
-
let accumulator = initial;
|
|
643
|
-
for (const node of this.walk()) accumulator = callback(accumulator, node);
|
|
644
|
-
return accumulator;
|
|
645
|
-
}
|
|
646
|
-
/** Runs a total catamorphism over the document using a {@link MarkdownHandlers} table. */
|
|
647
|
-
fold(handlers) {
|
|
648
|
-
return foldNode(this.#document, handlers, 0);
|
|
649
|
-
}
|
|
650
|
-
/**
|
|
651
|
-
* A web-standard {@link ReadableStream} over the document's top-level block nodes
|
|
652
|
-
* (shallow, source order) - a fresh, pull-based source per call: one block is
|
|
653
|
-
* enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
|
|
654
|
-
* async-iterable wherever the platform supports it (Node, Deno), and pipeable
|
|
655
|
-
* through any {@link TransformStream} / {@link WritableStream}.
|
|
656
|
-
*
|
|
657
|
-
* @example
|
|
658
|
-
* ```ts
|
|
659
|
-
* // universal - works in every ReadableStream-supporting environment
|
|
660
|
-
* const reader = markdown.stream().getReader()
|
|
661
|
-
* for (let result = await reader.read(); !result.done; result = await reader.read()) {
|
|
662
|
-
* console.log(result.value) // one BlockNode
|
|
663
|
-
* }
|
|
664
|
-
*
|
|
665
|
-
* // Node / Deno / Firefox support async iteration of ReadableStream natively;
|
|
666
|
-
* // other environments should use the reader loop above instead.
|
|
667
|
-
* for await (const block of markdown.stream()) {
|
|
668
|
-
* console.log(block)
|
|
669
|
-
* }
|
|
670
|
-
* ```
|
|
671
|
-
*/
|
|
672
|
-
stream() {
|
|
673
|
-
const blocks = this.#document.children;
|
|
674
|
-
let index = 0;
|
|
675
|
-
return new ReadableStream({ pull(controller) {
|
|
676
|
-
if (index < blocks.length) {
|
|
677
|
-
const block = blocks[index];
|
|
678
|
-
if (block === void 0) {
|
|
679
|
-
controller.close();
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
controller.enqueue(block);
|
|
683
|
-
index += 1;
|
|
684
|
-
} else controller.close();
|
|
685
|
-
} });
|
|
686
|
-
}
|
|
687
|
-
};
|
|
688
|
-
//#endregion
|
|
689
|
-
//#region src/core/shapers.ts
|
|
694
|
+
return {
|
|
695
|
+
text,
|
|
696
|
+
segments
|
|
697
|
+
};
|
|
698
|
+
}
|
|
690
699
|
/**
|
|
691
|
-
*
|
|
700
|
+
* Projects a derived text range through its segments to a half-open region of the
|
|
701
|
+
* original markdown string.
|
|
702
|
+
*
|
|
703
|
+
* @param source - The offset-bearing source carrying the range
|
|
704
|
+
* @param from - The inclusive derived-text boundary
|
|
705
|
+
* @param to - The exclusive derived-text boundary
|
|
706
|
+
* @returns The original-string span, or `undefined` when either boundary is unmapped
|
|
692
707
|
*
|
|
693
708
|
* @example
|
|
694
709
|
* ```ts
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
* const text = createContract(textShape)
|
|
699
|
-
* text.is({ element: 'text', value: 'hi' }) // true
|
|
710
|
+
* projectSpan({ text: 'a', segments: [{ offset: 0, start: 4, end: 5 }] }, 0, 1)
|
|
711
|
+
* // { start: 4, end: 5 }
|
|
700
712
|
* ```
|
|
701
713
|
*/
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
714
|
+
function projectSpan(source, from, to) {
|
|
715
|
+
if (from < 0 || to < from || to > source.text.length) return void 0;
|
|
716
|
+
let start;
|
|
717
|
+
let end;
|
|
718
|
+
for (let index = 0; index < source.segments.length; index += 1) {
|
|
719
|
+
const segment = source.segments[index];
|
|
720
|
+
if (segment === void 0) continue;
|
|
721
|
+
const next = source.segments[index + 1];
|
|
722
|
+
const limit = Math.min(segment.offset + (segment.end - segment.start), next === void 0 ? source.text.length : next.offset);
|
|
723
|
+
if (from === to && from >= segment.offset && from <= limit) {
|
|
724
|
+
if (next !== void 0 && from === next.offset) continue;
|
|
725
|
+
const position = from === limit ? segment.end : Math.min(segment.end, segment.start + from - segment.offset);
|
|
726
|
+
return {
|
|
727
|
+
start: position,
|
|
728
|
+
end: position
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
if (start === void 0 && from >= segment.offset && from < limit) start = segment.start + from - segment.offset;
|
|
732
|
+
if (to > segment.offset && to <= limit) end = to === limit ? segment.end : Math.min(segment.end, segment.start + to - segment.offset);
|
|
733
|
+
}
|
|
734
|
+
return start === void 0 || end === void 0 ? void 0 : {
|
|
735
|
+
start,
|
|
736
|
+
end
|
|
737
|
+
};
|
|
738
|
+
}
|
|
706
739
|
/**
|
|
707
|
-
*
|
|
740
|
+
* Trims an offset-bearing source without losing the coordinates of its retained text.
|
|
741
|
+
*
|
|
742
|
+
* @param source - The source to trim
|
|
743
|
+
* @returns The trimmed text and its narrowed original-string segments
|
|
708
744
|
*
|
|
709
745
|
* @example
|
|
710
746
|
* ```ts
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
* const codeSpan = createContract(codeSpanShape)
|
|
715
|
-
* codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
|
|
747
|
+
* trimSource({ text: ' a ', segments: [{ offset: 0, start: 4, end: 7 }] })
|
|
748
|
+
* // { text: 'a', segments: [{ offset: 0, start: 5, end: 6 }] }
|
|
716
749
|
* ```
|
|
717
750
|
*/
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
751
|
+
function trimSource(source) {
|
|
752
|
+
const start = source.text.length - source.text.trimStart().length;
|
|
753
|
+
const end = source.text.trimEnd().length;
|
|
754
|
+
return sliceSource(source, start, Math.max(start, end));
|
|
755
|
+
}
|
|
722
756
|
/**
|
|
723
|
-
*
|
|
757
|
+
* Normalizes one paragraph line while retaining the full source run consumed by a
|
|
758
|
+
* trailing-space hard break.
|
|
759
|
+
*
|
|
760
|
+
* @param source - The offset-bearing paragraph line
|
|
761
|
+
* @param breaks - If `true`, preserves a trailing run of at least two spaces as the
|
|
762
|
+
* scanner's two-space hard-break syntax; if `false`, trims the line normally
|
|
763
|
+
* @returns The normalized line and its original-string segments
|
|
724
764
|
*
|
|
725
765
|
* @example
|
|
726
766
|
* ```ts
|
|
727
|
-
*
|
|
728
|
-
* import { lineBreakShape } from '@src/core'
|
|
729
|
-
*
|
|
730
|
-
* const lineBreak = createContract(lineBreakShape)
|
|
731
|
-
* lineBreak.is({ element: 'break' }) // true
|
|
767
|
+
* normalizeParagraphLine(splitLines('text \nnext')[0], true).text // 'text '
|
|
732
768
|
* ```
|
|
733
769
|
*/
|
|
734
|
-
|
|
770
|
+
function normalizeParagraphLine(source, breaks) {
|
|
771
|
+
if (!breaks || !source.text.endsWith(" ")) return trimSource(source);
|
|
772
|
+
const contentEnd = source.text.trimEnd().length;
|
|
773
|
+
const content = trimSource(sliceSource(source, 0, contentEnd));
|
|
774
|
+
const span = projectSpan(source, contentEnd, source.text.length);
|
|
775
|
+
return joinSources([content, {
|
|
776
|
+
text: " ",
|
|
777
|
+
segments: span === void 0 ? [] : [{
|
|
778
|
+
offset: 0,
|
|
779
|
+
start: span.start,
|
|
780
|
+
end: span.end
|
|
781
|
+
}]
|
|
782
|
+
}], "");
|
|
783
|
+
}
|
|
735
784
|
/**
|
|
736
|
-
*
|
|
737
|
-
*
|
|
785
|
+
* Counts the leading space / tab characters on `line` (a tab counts as one) - the
|
|
786
|
+
* indent that decides whether a list item's continuation belongs to the item.
|
|
787
|
+
*
|
|
788
|
+
* @param line - The line to measure
|
|
789
|
+
* @returns The number of leading space / tab characters
|
|
738
790
|
*
|
|
739
791
|
* @example
|
|
740
792
|
* ```ts
|
|
741
|
-
*
|
|
742
|
-
* import { codeBlockShape } from '@src/core'
|
|
743
|
-
*
|
|
744
|
-
* const codeBlock = createContract(codeBlockShape)
|
|
745
|
-
* codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
|
|
746
|
-
* codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
|
|
793
|
+
* countIndent(' text') // 2
|
|
747
794
|
* ```
|
|
748
795
|
*/
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
796
|
+
function countIndent(line) {
|
|
797
|
+
let count = 0;
|
|
798
|
+
for (const character of line) if (character === " " || character === " ") count += 1;
|
|
799
|
+
else break;
|
|
800
|
+
return count;
|
|
801
|
+
}
|
|
754
802
|
/**
|
|
755
|
-
*
|
|
756
|
-
*
|
|
803
|
+
* Checks whether `character` is whitespace under the emphasis flanking rule - a space, a
|
|
804
|
+
* tab, or a newline.
|
|
805
|
+
*
|
|
806
|
+
* @param character - The character to test
|
|
807
|
+
* @returns True if the flanking rule counts it as whitespace; false otherwise
|
|
757
808
|
*
|
|
758
809
|
* @example
|
|
759
810
|
* ```ts
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
* const thematicBreak = createContract(thematicBreakShape)
|
|
764
|
-
* thematicBreak.is({ element: 'thematicBreak' }) // true
|
|
811
|
+
* isFlankingWhitespace(' ') // true
|
|
812
|
+
* isFlankingWhitespace('a') // false
|
|
765
813
|
* ```
|
|
766
814
|
*/
|
|
767
|
-
|
|
815
|
+
function isFlankingWhitespace(character) {
|
|
816
|
+
return character === " " || character === " " || character === "\n";
|
|
817
|
+
}
|
|
768
818
|
/**
|
|
769
|
-
*
|
|
770
|
-
*
|
|
819
|
+
* Checks whether `character` is escapable by a leading backslash - the ASCII punctuation
|
|
820
|
+
* markdown gives meaning to (so `\*` becomes `*` but `\.` stays `\.`).
|
|
821
|
+
*
|
|
822
|
+
* @param character - The single character after a backslash
|
|
823
|
+
* @returns True if a backslash before it is an escape; false otherwise
|
|
771
824
|
*
|
|
772
825
|
* @example
|
|
773
826
|
* ```ts
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
* const tableAlign = createContract(tableAlignShape)
|
|
778
|
-
* tableAlign.is('left') // true
|
|
779
|
-
* tableAlign.is('center') // true
|
|
780
|
-
* tableAlign.is('top') // false
|
|
827
|
+
* isEscapable('*') // true
|
|
828
|
+
* isEscapable('a') // false
|
|
781
829
|
* ```
|
|
782
830
|
*/
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
"center"
|
|
787
|
-
]);
|
|
831
|
+
function isEscapable(character) {
|
|
832
|
+
return /[\\`*_{}[\]()#+\-.!>~|]/.test(character);
|
|
833
|
+
}
|
|
788
834
|
/**
|
|
789
|
-
*
|
|
790
|
-
* line
|
|
791
|
-
*
|
|
835
|
+
* Checks whether `line` is blank - empty, or containing only whitespace - the markdown
|
|
836
|
+
* definition of a blank line that block parsing uses to separate paragraphs, skip
|
|
837
|
+
* gaps, and end list continuations.
|
|
838
|
+
*
|
|
839
|
+
* @param line - The candidate line
|
|
840
|
+
* @returns True if the line is blank; false otherwise
|
|
792
841
|
*
|
|
793
842
|
* @example
|
|
794
843
|
* ```ts
|
|
795
|
-
*
|
|
796
|
-
* import { listItemMatchShape } from '@src/core'
|
|
797
|
-
*
|
|
798
|
-
* const listItemParts = createContract(listItemMatchShape)
|
|
799
|
-
* listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
|
|
844
|
+
* isBlankLine(' ') // true
|
|
800
845
|
* ```
|
|
801
846
|
*/
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
content: (0, _orkestrel_contract.stringShape)(),
|
|
806
|
-
indent: (0, _orkestrel_contract.integerShape)(),
|
|
807
|
-
marker: (0, _orkestrel_contract.integerShape)()
|
|
808
|
-
});
|
|
809
|
-
//#endregion
|
|
810
|
-
//#region src/core/factories.ts
|
|
847
|
+
function isBlankLine(line) {
|
|
848
|
+
return (0, _orkestrel_contract.isEmptyString)(line.trim());
|
|
849
|
+
}
|
|
811
850
|
/**
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
* @remarks
|
|
816
|
-
* A block-bearing projection cannot also expose inline content. Callers may provide
|
|
817
|
-
* both views, but `inlines` is flushed whenever `blocks` is non-empty.
|
|
851
|
+
* Checks whether `line` is a blockquote line (`>` optionally indented up to three spaces) -
|
|
852
|
+
* its content is de-quoted by {@link stripQuote}.
|
|
818
853
|
*
|
|
819
|
-
* @param
|
|
820
|
-
* @returns
|
|
854
|
+
* @param line - The candidate line
|
|
855
|
+
* @returns True if the line begins a blockquote; false otherwise
|
|
821
856
|
*
|
|
822
857
|
* @example
|
|
823
858
|
* ```ts
|
|
824
|
-
*
|
|
825
|
-
* blocks: [{ element: 'thematicBreak' }],
|
|
826
|
-
* inlines: [{ element: 'text', value: 'discarded' }],
|
|
827
|
-
* })
|
|
828
|
-
* // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
|
|
859
|
+
* isQuote('> quoted') // true
|
|
829
860
|
* ```
|
|
830
861
|
*/
|
|
831
|
-
function
|
|
832
|
-
|
|
833
|
-
return {
|
|
834
|
-
blocks,
|
|
835
|
-
inlines: blocks.length === 0 ? parts.inlines ?? EMPTY_PROJECTION.inlines : [],
|
|
836
|
-
text: parts.text ?? EMPTY_PROJECTION.text,
|
|
837
|
-
cells: parts.cells ?? EMPTY_PROJECTION.cells,
|
|
838
|
-
rows: parts.rows ?? EMPTY_PROJECTION.rows
|
|
839
|
-
};
|
|
862
|
+
function isQuote(line) {
|
|
863
|
+
return /^\s{0,3}>/.test(line);
|
|
840
864
|
}
|
|
841
865
|
/**
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
* {@link MarkdownInterface} exposes.
|
|
845
|
-
*
|
|
846
|
-
* @remarks
|
|
847
|
-
* Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
|
|
848
|
-
* fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
|
|
849
|
-
* inline code / links / images / hard breaks) to build a render-agnostic
|
|
850
|
-
* {@link MarkdownDocument}. Given a
|
|
851
|
-
* {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
|
|
852
|
-
* value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
|
|
853
|
-
* degrades to text, never throws) and zero-dependency - a hand-written scanner, no
|
|
854
|
-
* regex-only structural parse, linear-time (no ReDoS).
|
|
866
|
+
* Checks whether `line` closes a fence opened by `marker` - the same fence character, a run
|
|
867
|
+
* at least as long, and nothing else but surrounding whitespace.
|
|
855
868
|
*
|
|
856
|
-
* @param
|
|
857
|
-
* @
|
|
869
|
+
* @param line - The candidate closing line
|
|
870
|
+
* @param marker - The opening fence's marker run (from {@link extractFence})
|
|
871
|
+
* @returns True if `line` closes the fence; false otherwise
|
|
858
872
|
*
|
|
859
873
|
* @example
|
|
860
874
|
* ```ts
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
* const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
|
|
864
|
-
* markdown.document.children[0] // { element: 'heading', ... }
|
|
875
|
+
* isFenceClose('```', '```') // true
|
|
865
876
|
* ```
|
|
866
877
|
*/
|
|
867
|
-
function
|
|
868
|
-
|
|
878
|
+
function isFenceClose(line, marker) {
|
|
879
|
+
const character = marker[0] === "~" ? "~" : "`";
|
|
880
|
+
let index = 0;
|
|
881
|
+
while (index < line.length && isFenceWhitespace(line[index])) index++;
|
|
882
|
+
let run = 0;
|
|
883
|
+
while (index < line.length && line[index] === character) {
|
|
884
|
+
run++;
|
|
885
|
+
index++;
|
|
886
|
+
}
|
|
887
|
+
if (run < marker.length) return false;
|
|
888
|
+
while (index < line.length && isFenceWhitespace(line[index])) index++;
|
|
889
|
+
return index === line.length;
|
|
869
890
|
}
|
|
870
891
|
/**
|
|
871
|
-
*
|
|
872
|
-
* {@link
|
|
873
|
-
* generator from one shape declaration (AGENTS §14).
|
|
892
|
+
* Checks whether `character` is a regex-`\s`-equivalent whitespace character - the
|
|
893
|
+
* character class {@link isFenceClose}'s scan treats as surrounding padding.
|
|
874
894
|
*
|
|
875
|
-
* @
|
|
895
|
+
* @param character - The single character to test, or `undefined` past the end of a line
|
|
896
|
+
* @returns True if it is whitespace; false otherwise
|
|
876
897
|
*
|
|
877
898
|
* @example
|
|
878
899
|
* ```ts
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
* const text = createTextContract()
|
|
882
|
-
* text.is({ element: 'text', value: 'hi' }) // true
|
|
900
|
+
* isFenceWhitespace(' ') // true
|
|
901
|
+
* isFenceWhitespace(undefined) // false
|
|
883
902
|
* ```
|
|
884
903
|
*/
|
|
885
|
-
function
|
|
886
|
-
return
|
|
904
|
+
function isFenceWhitespace(character) {
|
|
905
|
+
return character === " " || character === " " || character === "\n" || character === "\r" || character === "\f" || character === "\v";
|
|
887
906
|
}
|
|
888
907
|
/**
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
908
|
+
* Checks whether `line` is a thematic break (horizontal rule) - three or more of the SAME
|
|
909
|
+
* marker `-`, `*`, or `_` (optionally space-separated) and nothing else (`---`,
|
|
910
|
+
* `***`, `___`, `- - -`).
|
|
892
911
|
*
|
|
893
|
-
* @
|
|
912
|
+
* @param line - The candidate line
|
|
913
|
+
* @returns True if the line is a thematic break; false otherwise
|
|
894
914
|
*
|
|
895
915
|
* @example
|
|
896
916
|
* ```ts
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
* const codeSpan = createCodeSpanContract()
|
|
900
|
-
* codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
|
|
901
|
-
* ```
|
|
902
|
-
*/
|
|
903
|
-
function createCodeSpanContract() {
|
|
904
|
-
return (0, _orkestrel_contract.createContract)(codeSpanShape);
|
|
905
|
-
}
|
|
906
|
-
/**
|
|
907
|
-
* Compile the {@link lineBreakShape} into a {@link ContractInterface} for
|
|
908
|
-
* {@link LineBreakNode}.
|
|
909
|
-
*
|
|
910
|
-
* @returns A `LineBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
911
|
-
*
|
|
912
|
-
* @example
|
|
913
|
-
* ```ts
|
|
914
|
-
* import { createLineBreakContract } from '@src/core'
|
|
915
|
-
*
|
|
916
|
-
* createLineBreakContract().is({ element: 'break' }) // true
|
|
917
|
-
* ```
|
|
918
|
-
*/
|
|
919
|
-
function createLineBreakContract() {
|
|
920
|
-
return (0, _orkestrel_contract.createContract)(lineBreakShape);
|
|
921
|
-
}
|
|
922
|
-
/**
|
|
923
|
-
* Compile the {@link codeBlockShape} into a {@link ContractInterface} for
|
|
924
|
-
* {@link CodeBlockNode} - a guard, coercing parser, JSON Schema, and seeded
|
|
925
|
-
* generator from one shape declaration (AGENTS §14).
|
|
926
|
-
*
|
|
927
|
-
* @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
928
|
-
*
|
|
929
|
-
* @example
|
|
930
|
-
* ```ts
|
|
931
|
-
* import { createCodeBlockContract } from '@src/core'
|
|
932
|
-
*
|
|
933
|
-
* const codeBlock = createCodeBlockContract()
|
|
934
|
-
* codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
|
|
935
|
-
* ```
|
|
936
|
-
*/
|
|
937
|
-
function createCodeBlockContract() {
|
|
938
|
-
return (0, _orkestrel_contract.createContract)(codeBlockShape);
|
|
939
|
-
}
|
|
940
|
-
/**
|
|
941
|
-
* Compile the {@link thematicBreakShape} into a {@link ContractInterface} for
|
|
942
|
-
* {@link ThematicBreakNode} - a guard, coercing parser, JSON Schema, and
|
|
943
|
-
* seeded generator from one shape declaration (AGENTS §14).
|
|
944
|
-
*
|
|
945
|
-
* @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
946
|
-
*
|
|
947
|
-
* @example
|
|
948
|
-
* ```ts
|
|
949
|
-
* import { createThematicBreakContract } from '@src/core'
|
|
950
|
-
*
|
|
951
|
-
* const thematicBreak = createThematicBreakContract()
|
|
952
|
-
* thematicBreak.is({ element: 'thematicBreak' }) // true
|
|
953
|
-
* ```
|
|
954
|
-
*/
|
|
955
|
-
function createThematicBreakContract() {
|
|
956
|
-
return (0, _orkestrel_contract.createContract)(thematicBreakShape);
|
|
957
|
-
}
|
|
958
|
-
//#endregion
|
|
959
|
-
//#region src/core/helpers.ts
|
|
960
|
-
/**
|
|
961
|
-
* Normalize line endings to `\n` and split a markdown document into its lines - CRLF
|
|
962
|
-
* (`\r\n`) and bare CR (`\r`) both collapse to `\n` first, so a Windows-origin
|
|
963
|
-
* document parses identically. A single trailing newline does not yield a final
|
|
964
|
-
* empty line.
|
|
965
|
-
*
|
|
966
|
-
* @param markdown - The raw markdown source
|
|
967
|
-
* @returns The document's lines, line-terminators stripped
|
|
968
|
-
*
|
|
969
|
-
* @example
|
|
970
|
-
* ```ts
|
|
971
|
-
* splitLines('a\r\nb\nc') // ['a', 'b', 'c']
|
|
917
|
+
* isThematicBreak('---') // true
|
|
972
918
|
* ```
|
|
973
919
|
*/
|
|
974
|
-
function
|
|
975
|
-
const
|
|
976
|
-
if (
|
|
977
|
-
|
|
920
|
+
function isThematicBreak(line) {
|
|
921
|
+
const stripped = line.trim().replace(/\s+/g, "");
|
|
922
|
+
if (stripped.length < 3) return false;
|
|
923
|
+
const marker = stripped[0];
|
|
924
|
+
if (marker !== "-" && marker !== "*" && marker !== "_") return false;
|
|
925
|
+
return [...stripped].every((character) => character === marker);
|
|
978
926
|
}
|
|
979
927
|
/**
|
|
980
|
-
*
|
|
981
|
-
*
|
|
928
|
+
* Checks whether the pair (`header`, `delimiter`) opens a GFM table - `delimiter` is a row of
|
|
929
|
+
* `|`-separated cells each matching `:?-+:?`, the GFM rule that a table requires a
|
|
930
|
+
* header row IMMEDIATELY followed by a delimiter row.
|
|
982
931
|
*
|
|
983
|
-
* @param
|
|
984
|
-
* @
|
|
932
|
+
* @param header - The candidate header line
|
|
933
|
+
* @param delimiter - The line after it (the candidate delimiter)
|
|
934
|
+
* @returns True if the two lines open a table; false otherwise
|
|
985
935
|
*
|
|
986
936
|
* @example
|
|
987
937
|
* ```ts
|
|
988
|
-
*
|
|
938
|
+
* isTableStart('| a |', '| - |') // true
|
|
989
939
|
* ```
|
|
990
940
|
*/
|
|
991
|
-
function
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
return
|
|
941
|
+
function isTableStart(header, delimiter) {
|
|
942
|
+
if (delimiter === void 0 || !header.includes("|")) return false;
|
|
943
|
+
const cells = splitTableRow(delimiter);
|
|
944
|
+
if (cells.length === 0) return false;
|
|
945
|
+
return cells.every((cell) => /^:?-+:?$/.test(cell.trim()));
|
|
996
946
|
}
|
|
997
947
|
/**
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
* `#`s
|
|
1001
|
-
*
|
|
948
|
+
* Extracts an ATX heading line (`#` … `######` followed by text) into its level,
|
|
949
|
+
* trimmed text, and the text's offset inside the line. A run of more than 6 `#`s, or
|
|
950
|
+
* `#`s not followed by whitespace + text, is not a heading; an optional closing
|
|
951
|
+
* `###` run is stripped.
|
|
1002
952
|
*
|
|
1003
953
|
* @param line - The candidate line
|
|
1004
|
-
* @returns The heading level (1–6)
|
|
954
|
+
* @returns The heading level (1–6), raw inline text, and text offset, or `undefined`
|
|
1005
955
|
*
|
|
1006
956
|
* @example
|
|
1007
957
|
* ```ts
|
|
1008
|
-
* extractHeading('## Title') // { level: 2, text: 'Title' }
|
|
958
|
+
* extractHeading('## Title') // { level: 2, text: 'Title', offset: 3 }
|
|
1009
959
|
* ```
|
|
1010
960
|
*/
|
|
1011
961
|
function extractHeading(line) {
|
|
1012
|
-
const
|
|
962
|
+
const trimmed = line.trimStart();
|
|
963
|
+
const match = /^(#{1,6})(?:\s+(.*))?$/.exec(trimmed);
|
|
1013
964
|
if (!match || match[1] === void 0) return void 0;
|
|
965
|
+
const level = match[1].length;
|
|
966
|
+
const raw = match[2] ?? "";
|
|
967
|
+
const withoutClosing = raw.replace(/\s+#+\s*$/, "");
|
|
968
|
+
const text = withoutClosing.trim();
|
|
969
|
+
const found = raw.length === 0 ? trimmed.length : trimmed.indexOf(raw, level);
|
|
970
|
+
const content = found < 0 ? trimmed.length : found;
|
|
1014
971
|
return {
|
|
1015
|
-
level
|
|
1016
|
-
text
|
|
972
|
+
level,
|
|
973
|
+
text,
|
|
974
|
+
offset: line.length - trimmed.length + content + withoutClosing.length - withoutClosing.trimStart().length
|
|
1017
975
|
};
|
|
1018
976
|
}
|
|
1019
977
|
/**
|
|
1020
|
-
*
|
|
978
|
+
* Extracts a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
|
|
1021
979
|
* string) into its `{ marker, lang }`, or `undefined` when `line` is not a fence
|
|
1022
980
|
* opener. `marker` is the exact fence run (the closer must match the same character +
|
|
1023
981
|
* at least the same length); `lang` is the first word of the info string.
|
|
@@ -1042,7 +1000,7 @@ function extractFence(line) {
|
|
|
1042
1000
|
};
|
|
1043
1001
|
}
|
|
1044
1002
|
/**
|
|
1045
|
-
*
|
|
1003
|
+
* Extracts a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
|
|
1046
1004
|
* a space) into its {@link ListItemMatch}, or `undefined` when `line` is not a list
|
|
1047
1005
|
* item. `content` is the text after the marker; `marker` is the full marker-plus-space
|
|
1048
1006
|
* width (for measuring a continuation's indent).
|
|
@@ -1082,24 +1040,27 @@ function extractListItem(line) {
|
|
|
1082
1040
|
}
|
|
1083
1041
|
}
|
|
1084
1042
|
/**
|
|
1085
|
-
*
|
|
1086
|
-
* blockquote line, so the de-quoted
|
|
1043
|
+
* Strips one level of blockquote marker (`>` plus one optional following space) from
|
|
1044
|
+
* an offset-bearing blockquote line, so the de-quoted source re-parses as nested
|
|
1045
|
+
* blocks without losing its original coordinates.
|
|
1087
1046
|
*
|
|
1088
|
-
* @param
|
|
1089
|
-
* @returns The
|
|
1047
|
+
* @param source - A blockquote line (per {@link isQuote})
|
|
1048
|
+
* @returns The source with its leading `>` and optional space removed
|
|
1090
1049
|
*
|
|
1091
1050
|
* @example
|
|
1092
1051
|
* ```ts
|
|
1093
|
-
* stripQuote('> text'
|
|
1052
|
+
* stripQuote({ text: '> text', segments: [{ offset: 0, start: 0, end: 6 }] })
|
|
1053
|
+
* // { text: 'text', segments: [{ offset: 0, start: 2, end: 6 }] }
|
|
1094
1054
|
* ```
|
|
1095
1055
|
*/
|
|
1096
|
-
function stripQuote(
|
|
1097
|
-
return
|
|
1056
|
+
function stripQuote(source) {
|
|
1057
|
+
return sliceSource(source, (/^\s{0,3}>\s?/.exec(source.text)?.[0] ?? "").length, source.text.length);
|
|
1098
1058
|
}
|
|
1099
1059
|
/**
|
|
1100
|
-
*
|
|
1060
|
+
* Splits one GFM table row into its cell strings - outer pipes are optional, an escaped
|
|
1101
1061
|
* pipe (`\|`) inside a cell is NOT a separator (it becomes a literal `|`), and the
|
|
1102
|
-
* empty leading / trailing cell produced by an outer `|` is dropped.
|
|
1062
|
+
* empty leading / trailing cell produced by an outer `|` is dropped. Derives the string
|
|
1063
|
+
* form from {@link splitTableSources}, which owns the escaped-pipe splitting rule.
|
|
1103
1064
|
*
|
|
1104
1065
|
* @param row - The raw table row line
|
|
1105
1066
|
* @returns The row's cells, in column order
|
|
@@ -1110,26 +1071,59 @@ function stripQuote(line) {
|
|
|
1110
1071
|
* ```
|
|
1111
1072
|
*/
|
|
1112
1073
|
function splitTableRow(row) {
|
|
1074
|
+
return splitTableSources({
|
|
1075
|
+
text: row,
|
|
1076
|
+
segments: []
|
|
1077
|
+
}).map((cell) => cell.text);
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Splits an offset-bearing GFM table row into offset-bearing cells, retaining the
|
|
1081
|
+
* complete source spelling of an escaped pipe while exposing its literal value.
|
|
1082
|
+
*
|
|
1083
|
+
* @param row - The offset-bearing table row
|
|
1084
|
+
* @returns The row's cells with their original-string coordinates
|
|
1085
|
+
*
|
|
1086
|
+
* @example
|
|
1087
|
+
* ```ts
|
|
1088
|
+
* splitTableSources(splitLines('| a\\|b |')[0]).map((cell) => cell.text) // [' a|b ']
|
|
1089
|
+
* ```
|
|
1090
|
+
*/
|
|
1091
|
+
function splitTableSources(row) {
|
|
1092
|
+
const source = trimSource(row);
|
|
1113
1093
|
const cells = [];
|
|
1114
|
-
let
|
|
1115
|
-
|
|
1116
|
-
for (let index = 0; index <
|
|
1117
|
-
const character =
|
|
1118
|
-
if (character === "\\" &&
|
|
1119
|
-
|
|
1094
|
+
let pieces = [];
|
|
1095
|
+
let start = 0;
|
|
1096
|
+
for (let index = 0; index < source.text.length; index += 1) {
|
|
1097
|
+
const character = source.text[index];
|
|
1098
|
+
if (character === "\\" && source.text[index + 1] === "|") {
|
|
1099
|
+
pieces.push(sliceSource(source, start, index));
|
|
1100
|
+
const span = projectSpan(source, index, index + 2);
|
|
1101
|
+
pieces.push({
|
|
1102
|
+
text: "|",
|
|
1103
|
+
segments: span === void 0 ? [] : [{
|
|
1104
|
+
offset: 0,
|
|
1105
|
+
start: span.start,
|
|
1106
|
+
end: span.end
|
|
1107
|
+
}]
|
|
1108
|
+
});
|
|
1120
1109
|
index += 1;
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1110
|
+
start = index + 1;
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
if (character !== "|") continue;
|
|
1114
|
+
pieces.push(sliceSource(source, start, index));
|
|
1115
|
+
cells.push(joinSources(pieces, ""));
|
|
1116
|
+
pieces = [];
|
|
1117
|
+
start = index + 1;
|
|
1125
1118
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
if ((0, _orkestrel_contract.isNonEmptyArray)(cells) && (0, _orkestrel_contract.isEmptyString)((cells[
|
|
1119
|
+
pieces.push(sliceSource(source, start, source.text.length));
|
|
1120
|
+
cells.push(joinSources(pieces, ""));
|
|
1121
|
+
if ((0, _orkestrel_contract.isNonEmptyArray)(cells) && (0, _orkestrel_contract.isEmptyString)((cells[0]?.text ?? "").trim())) cells.shift();
|
|
1122
|
+
if ((0, _orkestrel_contract.isNonEmptyArray)(cells) && (0, _orkestrel_contract.isEmptyString)((cells[cells.length - 1]?.text ?? "").trim())) cells.pop();
|
|
1129
1123
|
return cells;
|
|
1130
1124
|
}
|
|
1131
1125
|
/**
|
|
1132
|
-
*
|
|
1126
|
+
* Derives the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
|
|
1133
1127
|
* left, `---:` right, `:---:` center, and `---` as the explicit no-alignment
|
|
1134
1128
|
* marker represented by `null`.
|
|
1135
1129
|
*
|
|
@@ -1153,7 +1147,7 @@ function delimiterToAlignments(delimiter) {
|
|
|
1153
1147
|
});
|
|
1154
1148
|
}
|
|
1155
1149
|
/**
|
|
1156
|
-
*
|
|
1150
|
+
* Checks whether the line at `index` starts a NEW block kind (heading / fence / thematic
|
|
1157
1151
|
* break / blockquote / list / table) - the paragraph collector stops at such a line
|
|
1158
1152
|
* so a block following a paragraph without a blank line still parses (a trusted-input
|
|
1159
1153
|
* caller writing a `##` heading directly under a paragraph, with no intervening blank
|
|
@@ -1161,7 +1155,7 @@ function delimiterToAlignments(delimiter) {
|
|
|
1161
1155
|
*
|
|
1162
1156
|
* @param lines - The document's lines
|
|
1163
1157
|
* @param index - The line index to test
|
|
1164
|
-
* @returns
|
|
1158
|
+
* @returns True if the line begins a different block; false otherwise
|
|
1165
1159
|
*
|
|
1166
1160
|
* @example
|
|
1167
1161
|
* ```ts
|
|
@@ -1173,7 +1167,7 @@ function startsBlock(lines, index) {
|
|
|
1173
1167
|
return extractHeading(line) !== void 0 || extractFence(line) !== void 0 || isThematicBreak(line) || isQuote(line) || extractListItem(line) !== void 0 || isTableStart(line, lines[index + 1]);
|
|
1174
1168
|
}
|
|
1175
1169
|
/**
|
|
1176
|
-
*
|
|
1170
|
+
* Resolves backslash escapes in a raw string to their literal characters - used for a
|
|
1177
1171
|
* link `href` (which is not otherwise inline-parsed) and any plain text run.
|
|
1178
1172
|
*
|
|
1179
1173
|
* @param text - The raw text possibly carrying `\x` escapes
|
|
@@ -1196,10 +1190,11 @@ function unescapeText(text) {
|
|
|
1196
1190
|
return out;
|
|
1197
1191
|
}
|
|
1198
1192
|
/**
|
|
1199
|
-
*
|
|
1193
|
+
* Merges adjacent text nodes into one - the inline scanner emits a text node per
|
|
1200
1194
|
* unrecognized character, so coalescing keeps the AST clean and assertion-friendly.
|
|
1201
1195
|
*
|
|
1202
1196
|
* @param nodes - The inline nodes (possibly with adjacent text runs)
|
|
1197
|
+
* @param spans - The optional operation-owned node span recorder
|
|
1203
1198
|
* @returns The nodes with consecutive text nodes concatenated
|
|
1204
1199
|
*
|
|
1205
1200
|
* @example
|
|
@@ -1208,20 +1203,32 @@ function unescapeText(text) {
|
|
|
1208
1203
|
* // [{ element: 'text', value: 'ab' }]
|
|
1209
1204
|
* ```
|
|
1210
1205
|
*/
|
|
1211
|
-
function coalesceText(nodes) {
|
|
1206
|
+
function coalesceText(nodes, spans) {
|
|
1212
1207
|
const out = [];
|
|
1213
1208
|
for (const node of nodes) {
|
|
1214
1209
|
const last = out[out.length - 1];
|
|
1215
|
-
if (node.element === "text" && last !== void 0 && last.element === "text")
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1210
|
+
if (node.element === "text" && last !== void 0 && last.element === "text") {
|
|
1211
|
+
const merged = {
|
|
1212
|
+
element: "text",
|
|
1213
|
+
value: last.value + node.value
|
|
1214
|
+
};
|
|
1215
|
+
const left = spans?.get(last);
|
|
1216
|
+
const right = spans?.get(node);
|
|
1217
|
+
if (spans !== void 0) {
|
|
1218
|
+
spans.delete(last);
|
|
1219
|
+
spans.delete(node);
|
|
1220
|
+
if (left !== void 0 && right !== void 0) spans.set(merged, {
|
|
1221
|
+
start: left.start,
|
|
1222
|
+
end: right.end
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
out[out.length - 1] = merged;
|
|
1226
|
+
} else out.push(node);
|
|
1220
1227
|
}
|
|
1221
1228
|
return out;
|
|
1222
1229
|
}
|
|
1223
1230
|
/**
|
|
1224
|
-
*
|
|
1231
|
+
* Scans an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
|
|
1225
1232
|
* SAME length, the CommonMark rule that lets a span contain backticks). Returns the
|
|
1226
1233
|
* span's literal text + end index, or `undefined` when no matching closer exists (it
|
|
1227
1234
|
* then degrades to literal backticks).
|
|
@@ -1256,26 +1263,22 @@ function scanCode(source, start, to) {
|
|
|
1256
1263
|
}
|
|
1257
1264
|
}
|
|
1258
1265
|
/**
|
|
1259
|
-
*
|
|
1266
|
+
* Locates a link `[text](href)` at `start` - the text runs to a BALANCED `]`, then `(`
|
|
1260
1267
|
* must immediately follow and the destination runs to the matching `)` (both respect
|
|
1261
|
-
* nested delimiters + escapes). Returns the
|
|
1268
|
+
* nested delimiters + escapes). Returns the label close and syntax end, or `undefined` when the shape
|
|
1262
1269
|
* does not hold (it then degrades to a literal `[`).
|
|
1263
1270
|
*
|
|
1264
1271
|
* @param source - The inline source text
|
|
1265
1272
|
* @param start - The index of the opening `[`
|
|
1266
1273
|
* @param to - The exclusive end of the scan window
|
|
1267
|
-
* @
|
|
1268
|
-
* at {@link MAX_DEPTH} the link's text children degrade to literal text instead of
|
|
1269
|
-
* recursing further
|
|
1270
|
-
* @returns The parsed {@link LinkNode} + end index, or `undefined`
|
|
1274
|
+
* @returns The label close and syntax end indices, or `undefined`
|
|
1271
1275
|
*
|
|
1272
1276
|
* @example
|
|
1273
1277
|
* ```ts
|
|
1274
|
-
*
|
|
1275
|
-
* // { node: { element: 'link', href: 'url', children: [...] }, end: 11 }
|
|
1278
|
+
* locateLink('[text](url)', 0, 11) // { close: 5, end: 11 }
|
|
1276
1279
|
* ```
|
|
1277
1280
|
*/
|
|
1278
|
-
function
|
|
1281
|
+
function locateLink(source, start, to) {
|
|
1279
1282
|
let bracketDepth = 0;
|
|
1280
1283
|
let close = -1;
|
|
1281
1284
|
for (let index = start; index < to; index += 1) {
|
|
@@ -1312,44 +1315,69 @@ function scanLink(source, start, to, depth = 0) {
|
|
|
1312
1315
|
}
|
|
1313
1316
|
}
|
|
1314
1317
|
if (parenClose === -1) return void 0;
|
|
1318
|
+
return {
|
|
1319
|
+
close,
|
|
1320
|
+
end: parenClose + 1
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Scans a link `[text](href)` at `start` - the text runs to a BALANCED `]`, then `(`
|
|
1325
|
+
* must immediately follow and the destination runs to the matching `)` (both respect
|
|
1326
|
+
* nested delimiters + escapes) through {@link locateLink}, and returns the parsed node
|
|
1327
|
+
* and end index. Returns `undefined` when the shape does not hold (it then degrades to
|
|
1328
|
+
* a literal `[`).
|
|
1329
|
+
*
|
|
1330
|
+
* @param source - The inline source text
|
|
1331
|
+
* @param start - The index of the opening `[`
|
|
1332
|
+
* @param to - The exclusive end of the scan window
|
|
1333
|
+
* @param depth - The current inline-recursion depth, forwarded to {@link scanInline}
|
|
1334
|
+
* incremented by one for the link text's children. At {@link MAX_DEPTH} that
|
|
1335
|
+
* recursion emits the text as a single literal text node instead of scanning it.
|
|
1336
|
+
* @returns The parsed link and end index, or `undefined` when the shape does not hold
|
|
1337
|
+
*
|
|
1338
|
+
* @example
|
|
1339
|
+
* ```ts
|
|
1340
|
+
* scanLink('[text](url)', 0, 11)
|
|
1341
|
+
* // { node: { element: 'link', href: 'url', children: [{ element: 'text', value: 'text' }] }, end: 11 }
|
|
1342
|
+
* ```
|
|
1343
|
+
*/
|
|
1344
|
+
function scanLink(source, start, to, depth = 0) {
|
|
1345
|
+
const located = locateLink(source, start, to);
|
|
1346
|
+
if (located === void 0) return void 0;
|
|
1315
1347
|
return {
|
|
1316
1348
|
node: {
|
|
1317
1349
|
element: "link",
|
|
1318
|
-
href: unescapeText(source.slice(close + 2,
|
|
1319
|
-
children: scanInline(source, start + 1, close, depth + 1)
|
|
1350
|
+
href: unescapeText(source.slice(located.close + 2, located.end - 1).trim()),
|
|
1351
|
+
children: scanInline(source, start + 1, located.close, depth + 1)
|
|
1320
1352
|
},
|
|
1321
|
-
end:
|
|
1353
|
+
end: located.end
|
|
1322
1354
|
};
|
|
1323
1355
|
}
|
|
1324
1356
|
/**
|
|
1325
|
-
*
|
|
1357
|
+
* Locates an emphasis run at `start` (`*` / `_`, doubled for strong) - finds the nearest
|
|
1326
1358
|
* matching closing run of the same marker + width while skipping complete nested
|
|
1327
1359
|
* runs from the other marker family, and requires non-space immediately inside both
|
|
1328
1360
|
* delimiters (the CommonMark flanking simplification that blocks `* x *`). Returns
|
|
1329
|
-
* the
|
|
1361
|
+
* the content and syntax bounds, or `undefined` when no valid closer exists (it then degrades to
|
|
1330
1362
|
* a literal marker).
|
|
1331
1363
|
*
|
|
1332
1364
|
* @param source - The inline source text
|
|
1333
1365
|
* @param start - The index of the opening marker
|
|
1334
1366
|
* @param to - The exclusive end of the scan window
|
|
1335
|
-
* @
|
|
1336
|
-
* at {@link MAX_DEPTH} the emphasis's children degrade to literal text instead of
|
|
1337
|
-
* recursing further
|
|
1338
|
-
* @returns The parsed {@link EmphasisNode} + end index, or `undefined`
|
|
1367
|
+
* @returns The content and syntax bounds, or `undefined`
|
|
1339
1368
|
*
|
|
1340
1369
|
* @example
|
|
1341
1370
|
* ```ts
|
|
1342
|
-
*
|
|
1343
|
-
* // { node: { element: 'emphasis', strong: false, children: [...] }, end: 4 }
|
|
1371
|
+
* locateEmphasis('*em*', 0, 4) // { strong: false, open: 1, close: 3, end: 4 }
|
|
1344
1372
|
* ```
|
|
1345
1373
|
*/
|
|
1346
|
-
function
|
|
1374
|
+
function locateEmphasis(source, start, to) {
|
|
1347
1375
|
const marker = source[start] ?? "";
|
|
1348
1376
|
let run = 0;
|
|
1349
1377
|
while (start + run < to && source[start + run] === marker && run < 2) run += 1;
|
|
1350
1378
|
const strong = run === 2;
|
|
1351
1379
|
const openEnd = start + run;
|
|
1352
|
-
if (openEnd >= to ||
|
|
1380
|
+
if (openEnd >= to || isFlankingWhitespace(source[openEnd] ?? "")) return void 0;
|
|
1353
1381
|
let index = openEnd;
|
|
1354
1382
|
while (index < to) {
|
|
1355
1383
|
const character = source[index] ?? "";
|
|
@@ -1363,7 +1391,7 @@ function scanEmphasis(source, start, to, depth = 0) {
|
|
|
1363
1391
|
continue;
|
|
1364
1392
|
}
|
|
1365
1393
|
if ((character === "*" || character === "_") && character !== marker) {
|
|
1366
|
-
const nested =
|
|
1394
|
+
const nested = locateEmphasis(source, index, to);
|
|
1367
1395
|
if (nested !== void 0) {
|
|
1368
1396
|
index = nested.end;
|
|
1369
1397
|
continue;
|
|
@@ -1372,12 +1400,10 @@ function scanEmphasis(source, start, to, depth = 0) {
|
|
|
1372
1400
|
if (character === marker) {
|
|
1373
1401
|
let closeRun = 0;
|
|
1374
1402
|
while (index + closeRun < to && source[index + closeRun] === marker) closeRun += 1;
|
|
1375
|
-
if (closeRun >= run && !
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
children: scanInline(source, openEnd, index, depth + 1)
|
|
1380
|
-
},
|
|
1403
|
+
if (closeRun >= run && !isFlankingWhitespace(source[index - 1] ?? "")) return {
|
|
1404
|
+
strong,
|
|
1405
|
+
open: openEnd,
|
|
1406
|
+
close: index,
|
|
1381
1407
|
end: index + run
|
|
1382
1408
|
};
|
|
1383
1409
|
index += closeRun;
|
|
@@ -1387,7 +1413,41 @@ function scanEmphasis(source, start, to, depth = 0) {
|
|
|
1387
1413
|
}
|
|
1388
1414
|
}
|
|
1389
1415
|
/**
|
|
1390
|
-
*
|
|
1416
|
+
* Scans an emphasis run at `start` (`*` / `_`, doubled for strong) - finds the nearest
|
|
1417
|
+
* matching closing run of the same marker + width while skipping complete nested runs
|
|
1418
|
+
* from the other marker family, and requires non-space immediately inside both
|
|
1419
|
+
* delimiters (the CommonMark flanking simplification that blocks `* x *`) through
|
|
1420
|
+
* {@link locateEmphasis}, and returns the parsed node and end index. Returns
|
|
1421
|
+
* `undefined` when no valid closer exists (it then degrades to a literal marker).
|
|
1422
|
+
*
|
|
1423
|
+
* @param source - The inline source text
|
|
1424
|
+
* @param start - The index of the opening marker
|
|
1425
|
+
* @param to - The exclusive end of the scan window
|
|
1426
|
+
* @param depth - The current inline-recursion depth, forwarded to {@link scanInline}
|
|
1427
|
+
* incremented by one for the run's children. At {@link MAX_DEPTH} that recursion
|
|
1428
|
+
* emits the content as a single literal text node instead of scanning it.
|
|
1429
|
+
* @returns The parsed emphasis and end index, or `undefined` when no closer exists
|
|
1430
|
+
*
|
|
1431
|
+
* @example
|
|
1432
|
+
* ```ts
|
|
1433
|
+
* scanEmphasis('*em*', 0, 4)
|
|
1434
|
+
* // { node: { element: 'emphasis', strong: false, children: [{ element: 'text', value: 'em' }] }, end: 4 }
|
|
1435
|
+
* ```
|
|
1436
|
+
*/
|
|
1437
|
+
function scanEmphasis(source, start, to, depth = 0) {
|
|
1438
|
+
const located = locateEmphasis(source, start, to);
|
|
1439
|
+
if (located === void 0) return void 0;
|
|
1440
|
+
return {
|
|
1441
|
+
node: {
|
|
1442
|
+
element: "emphasis",
|
|
1443
|
+
strong: located.strong,
|
|
1444
|
+
children: scanInline(source, located.open, located.close, depth + 1)
|
|
1445
|
+
},
|
|
1446
|
+
end: located.end
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Scans the window `[from, to)` of `source` into inline nodes - the single recursive
|
|
1391
1451
|
* engine the inline phase runs on (emphasis, link text, and image alternative
|
|
1392
1452
|
* content recurse through it). Linear:
|
|
1393
1453
|
* each character is consumed once; a failed construct emits its opening character as
|
|
@@ -1397,10 +1457,11 @@ function scanEmphasis(source, start, to, depth = 0) {
|
|
|
1397
1457
|
* @param from - The inclusive start of the scan window
|
|
1398
1458
|
* @param to - The exclusive end of the scan window
|
|
1399
1459
|
* @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
|
|
1400
|
-
* incremented by one on every recursive descent
|
|
1401
|
-
*
|
|
1402
|
-
*
|
|
1403
|
-
*
|
|
1460
|
+
* incremented by one on every recursive descent {@link scanInlineSource} makes into
|
|
1461
|
+
* itself for a link's text, an image's alternative content, or an emphasis run's
|
|
1462
|
+
* children. At {@link MAX_DEPTH} the window is never scanned for markup - it emits as
|
|
1463
|
+
* a single literal text node - so pathological nesting (`[[[[…`, `****…`) cannot
|
|
1464
|
+
* exhaust the call stack.
|
|
1404
1465
|
* @returns The parsed inline nodes (NOT yet coalesced)
|
|
1405
1466
|
*
|
|
1406
1467
|
* @example
|
|
@@ -1409,40 +1470,88 @@ function scanEmphasis(source, start, to, depth = 0) {
|
|
|
1409
1470
|
* ```
|
|
1410
1471
|
*/
|
|
1411
1472
|
function scanInline(source, from, to, depth = 0) {
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1473
|
+
return scanInlineSource({
|
|
1474
|
+
text: source,
|
|
1475
|
+
segments: [{
|
|
1476
|
+
offset: 0,
|
|
1477
|
+
start: 0,
|
|
1478
|
+
end: source.length
|
|
1479
|
+
}]
|
|
1480
|
+
}, from, to, /* @__PURE__ */ new Map(), depth);
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Scans an offset-bearing inline window with the same engine as {@link scanInline}
|
|
1484
|
+
* and records each emitted node against the original markdown string.
|
|
1485
|
+
*
|
|
1486
|
+
* @param source - The offset-bearing inline source
|
|
1487
|
+
* @param from - The inclusive start of the scan window
|
|
1488
|
+
* @param to - The exclusive end of the scan window
|
|
1489
|
+
* @param spans - The operation-owned node span recorder
|
|
1490
|
+
* @param depth - The current inline-recursion depth, incremented by one on every
|
|
1491
|
+
* recursive descent this function makes into itself for a link's text, an image's
|
|
1492
|
+
* alternative content, or an emphasis run's children
|
|
1493
|
+
* @returns The parsed inline nodes before adjacent text coalescing
|
|
1494
|
+
*
|
|
1495
|
+
* @example
|
|
1496
|
+
* ```ts
|
|
1497
|
+
* scanInlineSource(
|
|
1498
|
+
* { text: 'hi *there*', segments: [{ offset: 0, start: 0, end: 10 }] },
|
|
1499
|
+
* 0,
|
|
1500
|
+
* 10,
|
|
1501
|
+
* new Map(),
|
|
1502
|
+
* )
|
|
1503
|
+
* // [{ element: 'text', value: 'hi ' }, { element: 'emphasis', ... }]
|
|
1504
|
+
* ```
|
|
1505
|
+
*/
|
|
1506
|
+
function scanInlineSource(source, from, to, spans, depth = 0) {
|
|
1507
|
+
if (depth >= 64) if (from < to) {
|
|
1508
|
+
const node = {
|
|
1509
|
+
element: "text",
|
|
1510
|
+
value: source.text.slice(from, to)
|
|
1511
|
+
};
|
|
1512
|
+
const span = projectSpan(source, from, to);
|
|
1513
|
+
if (span !== void 0) spans.set(node, span);
|
|
1514
|
+
return [node];
|
|
1515
|
+
} else return [];
|
|
1416
1516
|
const nodes = [];
|
|
1417
1517
|
let index = from;
|
|
1418
1518
|
let pending = "";
|
|
1519
|
+
let pendingStart = from;
|
|
1419
1520
|
while (index < to) {
|
|
1420
|
-
const character = source[index] ?? "";
|
|
1421
|
-
if (character === "\\" && index + 1 < to && isEscapable(source[index + 1] ?? "")) {
|
|
1422
|
-
pending
|
|
1521
|
+
const character = source.text[index] ?? "";
|
|
1522
|
+
if (character === "\\" && index + 1 < to && isEscapable(source.text[index + 1] ?? "")) {
|
|
1523
|
+
if (pending.length === 0) pendingStart = index;
|
|
1524
|
+
pending += source.text[index + 1] ?? "";
|
|
1423
1525
|
index += 2;
|
|
1424
1526
|
continue;
|
|
1425
1527
|
}
|
|
1426
1528
|
if (character === " ") {
|
|
1427
1529
|
let spaceEnd = index;
|
|
1428
|
-
while (spaceEnd < to && source[spaceEnd] === " ") spaceEnd += 1;
|
|
1429
|
-
if (spaceEnd - index >= 2 && source[spaceEnd] === "\n") {
|
|
1530
|
+
while (spaceEnd < to && source.text[spaceEnd] === " ") spaceEnd += 1;
|
|
1531
|
+
if (spaceEnd - index >= 2 && source.text[spaceEnd] === "\n") {
|
|
1430
1532
|
if (pending.length > 0) {
|
|
1431
|
-
|
|
1533
|
+
const node = {
|
|
1432
1534
|
element: "text",
|
|
1433
1535
|
value: pending
|
|
1434
|
-
}
|
|
1536
|
+
};
|
|
1537
|
+
const span = projectSpan(source, pendingStart, index);
|
|
1538
|
+
if (span !== void 0) spans.set(node, span);
|
|
1539
|
+
nodes.push(node);
|
|
1435
1540
|
pending = "";
|
|
1436
1541
|
}
|
|
1437
|
-
|
|
1542
|
+
const node = { element: "break" };
|
|
1543
|
+
const span = projectSpan(source, index, spaceEnd + 1);
|
|
1544
|
+
if (span !== void 0) spans.set(node, span);
|
|
1545
|
+
nodes.push(node);
|
|
1438
1546
|
index = spaceEnd + 1;
|
|
1547
|
+
pendingStart = index;
|
|
1439
1548
|
continue;
|
|
1440
1549
|
}
|
|
1441
1550
|
}
|
|
1442
1551
|
let scanned;
|
|
1443
1552
|
let end = index;
|
|
1444
1553
|
if (character === "`") {
|
|
1445
|
-
const span = scanCode(source, index, to);
|
|
1554
|
+
const span = scanCode(source.text, index, to);
|
|
1446
1555
|
if (span) {
|
|
1447
1556
|
scanned = {
|
|
1448
1557
|
element: "codeSpan",
|
|
@@ -1451,50 +1560,70 @@ function scanInline(source, from, to, depth = 0) {
|
|
|
1451
1560
|
end = span.end;
|
|
1452
1561
|
}
|
|
1453
1562
|
}
|
|
1454
|
-
if (character === "!" && source[index + 1] === "[") {
|
|
1455
|
-
const link =
|
|
1456
|
-
if (link) {
|
|
1563
|
+
if (character === "!" && source.text[index + 1] === "[") {
|
|
1564
|
+
const link = locateLink(source.text, index + 1, to);
|
|
1565
|
+
if (link !== void 0) {
|
|
1457
1566
|
scanned = {
|
|
1458
1567
|
element: "image",
|
|
1459
|
-
src: link.
|
|
1460
|
-
children: link.
|
|
1568
|
+
src: unescapeText(source.text.slice(link.close + 2, link.end - 1).trim()),
|
|
1569
|
+
children: coalesceText(scanInlineSource(source, index + 2, link.close, spans, depth + 1), spans)
|
|
1461
1570
|
};
|
|
1462
1571
|
end = link.end;
|
|
1463
1572
|
}
|
|
1464
1573
|
}
|
|
1465
1574
|
if (character === "[") {
|
|
1466
|
-
const link =
|
|
1467
|
-
if (link) {
|
|
1468
|
-
scanned =
|
|
1575
|
+
const link = locateLink(source.text, index, to);
|
|
1576
|
+
if (link !== void 0) {
|
|
1577
|
+
scanned = {
|
|
1578
|
+
element: "link",
|
|
1579
|
+
href: unescapeText(source.text.slice(link.close + 2, link.end - 1).trim()),
|
|
1580
|
+
children: coalesceText(scanInlineSource(source, index + 1, link.close, spans, depth + 1), spans)
|
|
1581
|
+
};
|
|
1469
1582
|
end = link.end;
|
|
1470
1583
|
}
|
|
1471
1584
|
}
|
|
1472
1585
|
if (character === "*" || character === "_") {
|
|
1473
|
-
const emphasis =
|
|
1474
|
-
if (emphasis) {
|
|
1475
|
-
scanned =
|
|
1586
|
+
const emphasis = locateEmphasis(source.text, index, to);
|
|
1587
|
+
if (emphasis !== void 0) {
|
|
1588
|
+
scanned = {
|
|
1589
|
+
element: "emphasis",
|
|
1590
|
+
strong: emphasis.strong,
|
|
1591
|
+
children: coalesceText(scanInlineSource(source, emphasis.open, emphasis.close, spans, depth + 1), spans)
|
|
1592
|
+
};
|
|
1476
1593
|
end = emphasis.end;
|
|
1477
1594
|
}
|
|
1478
1595
|
}
|
|
1479
1596
|
if (scanned !== void 0) {
|
|
1480
1597
|
if (pending.length > 0) {
|
|
1481
|
-
|
|
1598
|
+
const node = {
|
|
1482
1599
|
element: "text",
|
|
1483
1600
|
value: pending
|
|
1484
|
-
}
|
|
1601
|
+
};
|
|
1602
|
+
const span = projectSpan(source, pendingStart, index);
|
|
1603
|
+
if (span !== void 0) spans.set(node, span);
|
|
1604
|
+
nodes.push(node);
|
|
1485
1605
|
pending = "";
|
|
1486
1606
|
}
|
|
1607
|
+
const span = projectSpan(source, index, end);
|
|
1608
|
+
if (span !== void 0) spans.set(scanned, span);
|
|
1487
1609
|
nodes.push(scanned);
|
|
1488
1610
|
index = end;
|
|
1611
|
+
pendingStart = index;
|
|
1489
1612
|
continue;
|
|
1490
1613
|
}
|
|
1614
|
+
if (pending.length === 0) pendingStart = index;
|
|
1491
1615
|
pending += character;
|
|
1492
1616
|
index += 1;
|
|
1493
1617
|
}
|
|
1494
|
-
if (pending.length > 0)
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1618
|
+
if (pending.length > 0) {
|
|
1619
|
+
const node = {
|
|
1620
|
+
element: "text",
|
|
1621
|
+
value: pending
|
|
1622
|
+
};
|
|
1623
|
+
const span = projectSpan(source, pendingStart, index);
|
|
1624
|
+
if (span !== void 0) spans.set(node, span);
|
|
1625
|
+
nodes.push(node);
|
|
1626
|
+
}
|
|
1498
1627
|
return nodes;
|
|
1499
1628
|
}
|
|
1500
1629
|
/**
|
|
@@ -1503,36 +1632,56 @@ function scanInline(source, from, to, depth = 0) {
|
|
|
1503
1632
|
*
|
|
1504
1633
|
* @param lines - The markdown lines to scan.
|
|
1505
1634
|
* @param start - The index of the header row.
|
|
1635
|
+
* @param spans - The optional operation-owned node span recorder.
|
|
1506
1636
|
* @returns The parsed table node and the index of the first line after it.
|
|
1507
1637
|
*
|
|
1508
1638
|
* @example
|
|
1509
1639
|
* ```ts
|
|
1510
|
-
* collectTable(
|
|
1640
|
+
* collectTable(splitLines('| a |\n| - |'), 0) // { node: { element: 'table', ... }, next: 2 }
|
|
1511
1641
|
* ```
|
|
1512
1642
|
*/
|
|
1513
|
-
function collectTable(lines, start) {
|
|
1514
|
-
const headerCells =
|
|
1643
|
+
function collectTable(lines, start, spans = /* @__PURE__ */ new Map()) {
|
|
1644
|
+
const headerCells = splitTableSources(lines[start] ?? {
|
|
1645
|
+
text: "",
|
|
1646
|
+
segments: []
|
|
1647
|
+
});
|
|
1515
1648
|
const columns = headerCells.length;
|
|
1516
|
-
const header = headerCells.map((cell) =>
|
|
1517
|
-
|
|
1649
|
+
const header = headerCells.map((cell) => {
|
|
1650
|
+
const source = trimSource(cell);
|
|
1651
|
+
return coalesceText(scanInlineSource(source, 0, source.text.length, spans), spans);
|
|
1652
|
+
});
|
|
1653
|
+
const align = delimiterToAlignments(lines[start + 1]?.text ?? "");
|
|
1518
1654
|
const padded = [];
|
|
1519
1655
|
for (let column = 0; column < columns; column += 1) padded.push(align[column] ?? null);
|
|
1520
1656
|
const rows = [];
|
|
1521
1657
|
let index = start + 2;
|
|
1522
|
-
while (index < lines.length && !isBlankLine(lines[index] ?? "") && (lines[index] ?? "").includes("|")) {
|
|
1523
|
-
const cells =
|
|
1658
|
+
while (index < lines.length && !isBlankLine(lines[index]?.text ?? "") && (lines[index]?.text ?? "").includes("|")) {
|
|
1659
|
+
const cells = splitTableSources(lines[index] ?? {
|
|
1660
|
+
text: "",
|
|
1661
|
+
segments: []
|
|
1662
|
+
});
|
|
1524
1663
|
const row = [];
|
|
1525
|
-
for (let column = 0; column < columns; column += 1)
|
|
1664
|
+
for (let column = 0; column < columns; column += 1) {
|
|
1665
|
+
const source = trimSource(cells[column] ?? {
|
|
1666
|
+
text: "",
|
|
1667
|
+
segments: []
|
|
1668
|
+
});
|
|
1669
|
+
row.push(coalesceText(scanInlineSource(source, 0, source.text.length, spans), spans));
|
|
1670
|
+
}
|
|
1526
1671
|
rows.push(row);
|
|
1527
1672
|
index += 1;
|
|
1528
1673
|
}
|
|
1674
|
+
const node = {
|
|
1675
|
+
element: "table",
|
|
1676
|
+
header,
|
|
1677
|
+
rows,
|
|
1678
|
+
align: padded
|
|
1679
|
+
};
|
|
1680
|
+
const source = joinSources(lines.slice(start, index), "\n");
|
|
1681
|
+
const span = projectSpan(source, 0, source.text.length);
|
|
1682
|
+
if (span !== void 0) spans.set(node, span);
|
|
1529
1683
|
return {
|
|
1530
|
-
node
|
|
1531
|
-
element: "table",
|
|
1532
|
-
header,
|
|
1533
|
-
rows,
|
|
1534
|
-
align: padded
|
|
1535
|
-
},
|
|
1684
|
+
node,
|
|
1536
1685
|
next: index
|
|
1537
1686
|
};
|
|
1538
1687
|
}
|
|
@@ -1543,15 +1692,18 @@ function collectTable(lines, start) {
|
|
|
1543
1692
|
* @param lines - The markdown lines to scan.
|
|
1544
1693
|
* @param start - The index of the first list item.
|
|
1545
1694
|
* @param depth - The current recursion depth (each item recurses at `depth + 1`).
|
|
1695
|
+
* @param spans - The optional operation-owned node span recorder.
|
|
1696
|
+
* @param end - The original-source end of this line run, including a removed terminator.
|
|
1546
1697
|
* @returns The parsed list node and the index of the first line after it.
|
|
1547
1698
|
*
|
|
1548
1699
|
* @example
|
|
1549
1700
|
* ```ts
|
|
1550
|
-
* collectList(
|
|
1701
|
+
* collectList(splitLines('- item'), 0, 0) // { node: { element: 'list', ... }, next: 1 }
|
|
1551
1702
|
* ```
|
|
1552
1703
|
*/
|
|
1553
|
-
function collectList(lines, start, depth) {
|
|
1554
|
-
const
|
|
1704
|
+
function collectList(lines, start, depth, spans = /* @__PURE__ */ new Map(), end) {
|
|
1705
|
+
const text = lines.map((line) => line.text);
|
|
1706
|
+
const first = extractListItem(text[start] ?? "");
|
|
1555
1707
|
const ordered = first?.ordered ?? false;
|
|
1556
1708
|
const startOrdinal = first?.start ?? 1;
|
|
1557
1709
|
const topIndent = first?.indent ?? 0;
|
|
@@ -1559,7 +1711,7 @@ function collectList(lines, start, depth) {
|
|
|
1559
1711
|
const chain = [];
|
|
1560
1712
|
let nested = true;
|
|
1561
1713
|
for (let cursor = start; cursor < lines.length; cursor += 1) {
|
|
1562
|
-
const parsed = extractListItem(
|
|
1714
|
+
const parsed = extractListItem(text[cursor] ?? "");
|
|
1563
1715
|
const previous = chain[chain.length - 1];
|
|
1564
1716
|
if (parsed === void 0 || previous !== void 0 && (previous.content.length > 0 || parsed.indent !== previous.marker)) {
|
|
1565
1717
|
nested = false;
|
|
@@ -1570,29 +1722,48 @@ function collectList(lines, start, depth) {
|
|
|
1570
1722
|
const remaining = 64 - depth;
|
|
1571
1723
|
if (nested && remaining > 0 && chain.length > remaining) {
|
|
1572
1724
|
const terminal = chain[remaining - 1];
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
let
|
|
1725
|
+
const terminalLine = lines[start + remaining - 1];
|
|
1726
|
+
if (terminal !== void 0 && terminalLine !== void 0) {
|
|
1727
|
+
const sources = [sliceSource(terminalLine, terminal.marker, terminalLine.text.length)];
|
|
1728
|
+
for (let cursor = start + remaining; cursor < lines.length; cursor += 1) {
|
|
1729
|
+
const line = lines[cursor];
|
|
1730
|
+
if (line !== void 0) sources.push(sliceSource(line, terminal.marker, line.text.length));
|
|
1731
|
+
}
|
|
1732
|
+
const source = joinSources(sources, "\n");
|
|
1733
|
+
const textNode = {
|
|
1734
|
+
element: "text",
|
|
1735
|
+
value: source.text
|
|
1736
|
+
};
|
|
1737
|
+
const paragraph = {
|
|
1577
1738
|
element: "paragraph",
|
|
1578
|
-
children: [
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1739
|
+
children: [textNode]
|
|
1740
|
+
};
|
|
1741
|
+
const residualSpan = projectSpan(source, 0, source.text.length);
|
|
1742
|
+
if (residualSpan !== void 0) {
|
|
1743
|
+
spans.set(textNode, residualSpan);
|
|
1744
|
+
spans.set(paragraph, residualSpan);
|
|
1745
|
+
}
|
|
1746
|
+
let children = [paragraph];
|
|
1583
1747
|
let node;
|
|
1584
1748
|
for (let cursor = remaining - 1; cursor >= 0; cursor -= 1) {
|
|
1585
1749
|
const parsed = chain[cursor];
|
|
1586
1750
|
if (parsed === void 0) continue;
|
|
1751
|
+
const item = {
|
|
1752
|
+
element: "listItem",
|
|
1753
|
+
children
|
|
1754
|
+
};
|
|
1587
1755
|
node = {
|
|
1588
1756
|
element: "list",
|
|
1589
1757
|
ordered: parsed.ordered,
|
|
1590
1758
|
start: parsed.start,
|
|
1591
|
-
items: [
|
|
1592
|
-
element: "listItem",
|
|
1593
|
-
children
|
|
1594
|
-
}]
|
|
1759
|
+
items: [item]
|
|
1595
1760
|
};
|
|
1761
|
+
const region = joinSources(lines.slice(start + cursor).map((line) => sliceSource(line, parsed.indent, line.text.length)), "\n");
|
|
1762
|
+
const span = projectSpan(region, 0, region.text.length);
|
|
1763
|
+
if (span !== void 0) {
|
|
1764
|
+
spans.set(item, span);
|
|
1765
|
+
spans.set(node, span);
|
|
1766
|
+
}
|
|
1596
1767
|
children = [node];
|
|
1597
1768
|
}
|
|
1598
1769
|
if (node !== void 0) return {
|
|
@@ -1603,48 +1774,64 @@ function collectList(lines, start, depth) {
|
|
|
1603
1774
|
}
|
|
1604
1775
|
let index = start;
|
|
1605
1776
|
while (index < lines.length) {
|
|
1606
|
-
const parsed = extractListItem(
|
|
1777
|
+
const parsed = extractListItem(text[index] ?? "");
|
|
1607
1778
|
if (!parsed || parsed.indent > topIndent || parsed.ordered !== ordered) break;
|
|
1608
|
-
const
|
|
1779
|
+
const itemStart = index;
|
|
1780
|
+
const itemLine = lines[index];
|
|
1781
|
+
if (itemLine === void 0) break;
|
|
1782
|
+
const itemLines = [sliceSource(itemLine, parsed.marker, itemLine.text.length)];
|
|
1609
1783
|
const continuation = parsed.marker;
|
|
1610
1784
|
index += 1;
|
|
1611
1785
|
while (index < lines.length) {
|
|
1612
|
-
const
|
|
1786
|
+
const nextSource = lines[index];
|
|
1787
|
+
if (nextSource === void 0) break;
|
|
1788
|
+
const next = nextSource.text;
|
|
1613
1789
|
if (isBlankLine(next)) {
|
|
1614
|
-
const after = lines[index + 1] ?? "";
|
|
1790
|
+
const after = lines[index + 1]?.text ?? "";
|
|
1615
1791
|
if (index + 1 < lines.length && !isBlankLine(after) && countIndent(after) >= continuation) {
|
|
1616
|
-
itemLines.push(
|
|
1792
|
+
itemLines.push(sliceSource(nextSource, 0, 0));
|
|
1617
1793
|
index += 1;
|
|
1618
1794
|
continue;
|
|
1619
1795
|
}
|
|
1620
1796
|
break;
|
|
1621
1797
|
}
|
|
1622
1798
|
if (countIndent(next) >= continuation) {
|
|
1623
|
-
itemLines.push(next.
|
|
1799
|
+
itemLines.push(sliceSource(nextSource, continuation, next.length));
|
|
1624
1800
|
index += 1;
|
|
1625
1801
|
continue;
|
|
1626
1802
|
}
|
|
1627
|
-
if (extractListItem(next) || startsBlock(
|
|
1628
|
-
itemLines.push(
|
|
1803
|
+
if (extractListItem(next) || startsBlock(text, index)) break;
|
|
1804
|
+
itemLines.push(trimSource(nextSource));
|
|
1629
1805
|
index += 1;
|
|
1630
1806
|
}
|
|
1631
|
-
|
|
1807
|
+
const tail = itemLines[itemLines.length - 1];
|
|
1808
|
+
const segment = tail?.segments[tail.segments.length - 1];
|
|
1809
|
+
const itemEnd = index === lines.length && end !== void 0 ? end : segment?.end;
|
|
1810
|
+
const item = {
|
|
1632
1811
|
element: "listItem",
|
|
1633
|
-
children: parseBlocks(itemLines, depth + 1)
|
|
1634
|
-
}
|
|
1812
|
+
children: parseBlocks(itemLines, depth + 1, spans, itemEnd)
|
|
1813
|
+
};
|
|
1814
|
+
const source = joinSources(lines.slice(itemStart, index), "\n");
|
|
1815
|
+
const span = projectSpan(source, 0, source.text.length);
|
|
1816
|
+
if (span !== void 0) spans.set(item, span);
|
|
1817
|
+
items.push(item);
|
|
1635
1818
|
}
|
|
1819
|
+
const node = {
|
|
1820
|
+
element: "list",
|
|
1821
|
+
ordered,
|
|
1822
|
+
start: startOrdinal,
|
|
1823
|
+
items
|
|
1824
|
+
};
|
|
1825
|
+
const source = joinSources(lines.slice(start, index), "\n");
|
|
1826
|
+
const span = projectSpan(source, 0, source.text.length);
|
|
1827
|
+
if (span !== void 0) spans.set(node, span);
|
|
1636
1828
|
return {
|
|
1637
|
-
node
|
|
1638
|
-
element: "list",
|
|
1639
|
-
ordered,
|
|
1640
|
-
start: startOrdinal,
|
|
1641
|
-
items
|
|
1642
|
-
},
|
|
1829
|
+
node,
|
|
1643
1830
|
next: index
|
|
1644
1831
|
};
|
|
1645
1832
|
}
|
|
1646
1833
|
/**
|
|
1647
|
-
*
|
|
1834
|
+
* Projects a {@link MarkdownNode} into an unsanitized {@link HTMLDocument}.
|
|
1648
1835
|
*
|
|
1649
1836
|
* @remarks
|
|
1650
1837
|
* The projection is pure and iterative. Text and attribute values remain literal for
|
|
@@ -1973,29 +2160,7 @@ function markdownToHTML(node) {
|
|
|
1973
2160
|
};
|
|
1974
2161
|
}
|
|
1975
2162
|
/**
|
|
1976
|
-
*
|
|
1977
|
-
*
|
|
1978
|
-
* @remarks
|
|
1979
|
-
* Markdown widens `@orkestrel/html`'s attribute floor by exactly `src`, because image
|
|
1980
|
-
* syntax is meaningless without its source. `src` is still a URL attribute, so the
|
|
1981
|
-
* floor refuses `javascript:`, `data:`, `vbscript:`, and `file:` values. A stricter
|
|
1982
|
-
* consumer can compose {@link markdownToHTML} with `@orkestrel/html`'s `HTML` class
|
|
1983
|
-
* directly.
|
|
1984
|
-
*
|
|
1985
|
-
* @param node - The markdown document or bare node to render
|
|
1986
|
-
* @returns Sanitized canonical HTML
|
|
1987
|
-
*
|
|
1988
|
-
* @example
|
|
1989
|
-
* ```ts
|
|
1990
|
-
* renderHTML({ element: 'paragraph', children: [{ element: 'text', value: 'a & b' }] })
|
|
1991
|
-
* // '<p>a & b</p>'
|
|
1992
|
-
* ```
|
|
1993
|
-
*/
|
|
1994
|
-
function renderHTML(node) {
|
|
1995
|
-
return (0, _orkestrel_html.renderHTML)(new _orkestrel_html.HTML(markdownToHTML(node)).sanitize({ attributes: [..._orkestrel_html.SAFE_ATTRIBUTES, "src"] }).document);
|
|
1996
|
-
}
|
|
1997
|
-
/**
|
|
1998
|
-
* Render a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
|
|
2163
|
+
* Renders a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
|
|
1999
2164
|
* projection of `renderHTML`, and the serializer a `parse(renderMarkdown(doc))`
|
|
2000
2165
|
* round-trip is built on. Canonical forms: `*` / `**` emphasis at even emphasis
|
|
2001
2166
|
* nesting depths and `_` / `__` at odd depths, `- ` bullets, `N. ` sequential
|
|
@@ -2004,8 +2169,8 @@ function renderHTML(node) {
|
|
|
2004
2169
|
* `> `-prefixed blockquote lines, GFM tables (1-space-padded cells, `\|`-escaped
|
|
2005
2170
|
* pipes, an alignment delimiter row), `[text](href)` links, `` images,
|
|
2006
2171
|
* and two-space hard breaks. A `text` node's literal content is backslash-escaped
|
|
2007
|
-
* wherever it would otherwise re-parse as markup
|
|
2008
|
-
*
|
|
2172
|
+
* wherever it would otherwise re-parse as markup, so parsing the rendered source
|
|
2173
|
+
* returns the node it was rendered from.
|
|
2009
2174
|
*
|
|
2010
2175
|
* @remarks
|
|
2011
2176
|
* Total: never throws. At {@link MAX_DEPTH} a value-bearing node degrades to its
|
|
@@ -2290,31 +2455,61 @@ function renderMarkdown(node) {
|
|
|
2290
2455
|
return "";
|
|
2291
2456
|
}
|
|
2292
2457
|
/**
|
|
2293
|
-
*
|
|
2294
|
-
*
|
|
2295
|
-
* node when nothing survives.
|
|
2458
|
+
* Builds an HTML-to-markdown projection with absent fields defaulted from
|
|
2459
|
+
* {@link EMPTY_PROJECTION} and the block/inline exclusivity invariant enforced.
|
|
2296
2460
|
*
|
|
2297
2461
|
* @remarks
|
|
2298
|
-
*
|
|
2299
|
-
*
|
|
2300
|
-
* run (see {@link coalesceText}): only the outermost node on each side is examined.
|
|
2462
|
+
* A block-bearing projection cannot also expose inline content. Callers may provide
|
|
2463
|
+
* both views, but `inlines` is flushed whenever `blocks` is non-empty.
|
|
2301
2464
|
*
|
|
2302
|
-
* @param
|
|
2303
|
-
* @returns
|
|
2465
|
+
* @param parts - The projection fields to provide
|
|
2466
|
+
* @returns A complete invariant-preserving projection
|
|
2304
2467
|
*
|
|
2305
2468
|
* @example
|
|
2306
2469
|
* ```ts
|
|
2307
|
-
*
|
|
2470
|
+
* createProjection({
|
|
2471
|
+
* blocks: [{ element: 'thematicBreak' }],
|
|
2472
|
+
* inlines: [{ element: 'text', value: 'discarded' }],
|
|
2473
|
+
* })
|
|
2474
|
+
* // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
|
|
2308
2475
|
* ```
|
|
2309
2476
|
*/
|
|
2310
|
-
function
|
|
2311
|
-
const
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2477
|
+
function createProjection(parts = {}) {
|
|
2478
|
+
const blocks = parts.blocks ?? EMPTY_PROJECTION.blocks;
|
|
2479
|
+
return {
|
|
2480
|
+
blocks,
|
|
2481
|
+
inlines: blocks.length === 0 ? parts.inlines ?? EMPTY_PROJECTION.inlines : [],
|
|
2482
|
+
text: parts.text ?? EMPTY_PROJECTION.text,
|
|
2483
|
+
cells: parts.cells ?? EMPTY_PROJECTION.cells,
|
|
2484
|
+
rows: parts.rows ?? EMPTY_PROJECTION.rows
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* Trims the whitespace at the two ends of an inline run - the leading whitespace of a
|
|
2489
|
+
* leading text node and the trailing whitespace of a trailing one - dropping either
|
|
2490
|
+
* node when nothing survives.
|
|
2491
|
+
*
|
|
2492
|
+
* @remarks
|
|
2493
|
+
* Markdown trims every line of a paragraph, a heading's text, and a table cell, so an
|
|
2494
|
+
* untrimmed run would come back from a re-parse a different AST. Expects a coalesced
|
|
2495
|
+
* run (see {@link coalesceText}): only the outermost node on each side is examined.
|
|
2496
|
+
*
|
|
2497
|
+
* @param nodes - The inline run to trim
|
|
2498
|
+
* @returns The run with its edge whitespace removed
|
|
2499
|
+
*
|
|
2500
|
+
* @example
|
|
2501
|
+
* ```ts
|
|
2502
|
+
* trimInlines([{ element: 'text', value: ' a ' }]) // [{ element: 'text', value: 'a' }]
|
|
2503
|
+
* ```
|
|
2504
|
+
*/
|
|
2505
|
+
function trimInlines(nodes) {
|
|
2506
|
+
const out = [];
|
|
2507
|
+
for (const node of nodes) if (node !== void 0) out.push(node);
|
|
2508
|
+
const first = out[0];
|
|
2509
|
+
if (first !== void 0 && first.element === "text") {
|
|
2510
|
+
const value = first.value.replace(/^\s+/, "");
|
|
2511
|
+
if ((0, _orkestrel_contract.isEmptyString)(value)) out.shift();
|
|
2512
|
+
else out[0] = {
|
|
2318
2513
|
element: "text",
|
|
2319
2514
|
value
|
|
2320
2515
|
};
|
|
@@ -2331,7 +2526,7 @@ function trimInlines(nodes) {
|
|
|
2331
2526
|
return out;
|
|
2332
2527
|
}
|
|
2333
2528
|
/**
|
|
2334
|
-
*
|
|
2529
|
+
* Reduces an inline run to the shape markdown can actually write back: adjacent text
|
|
2335
2530
|
* coalesced, empty text dropped, and every hard break either kept as a real line
|
|
2336
2531
|
* ending or spent as a space.
|
|
2337
2532
|
*
|
|
@@ -2344,8 +2539,8 @@ function trimInlines(nodes) {
|
|
|
2344
2539
|
* becomes the space it stood for.
|
|
2345
2540
|
*
|
|
2346
2541
|
* @param nodes - The inline run to normalize
|
|
2347
|
-
* @param breaks -
|
|
2348
|
-
*
|
|
2542
|
+
* @param breaks - If `true`, keeps each hard break as a real line ending; if `false`, spends
|
|
2543
|
+
* every break as the space it stood for, as a heading or a table cell requires
|
|
2349
2544
|
* @returns The normalized run
|
|
2350
2545
|
*
|
|
2351
2546
|
* @example
|
|
@@ -2396,7 +2591,7 @@ function normalizeInlines(nodes, breaks) {
|
|
|
2396
2591
|
return coalesceText(out);
|
|
2397
2592
|
}
|
|
2398
2593
|
/**
|
|
2399
|
-
*
|
|
2594
|
+
* Combines the projections of one node's children into the projection of that node -
|
|
2400
2595
|
* the single place inline runs become paragraphs, so no ancestor has to decide it
|
|
2401
2596
|
* twice.
|
|
2402
2597
|
*
|
|
@@ -2483,7 +2678,7 @@ function mergeProjections(children) {
|
|
|
2483
2678
|
});
|
|
2484
2679
|
}
|
|
2485
2680
|
/**
|
|
2486
|
-
*
|
|
2681
|
+
* Reads a projection as BLOCK content - the view a document, a blockquote, and a list
|
|
2487
2682
|
* item each need.
|
|
2488
2683
|
*
|
|
2489
2684
|
* @remarks
|
|
@@ -2529,7 +2724,7 @@ function projectionToBlocks(projection) {
|
|
|
2529
2724
|
return blocks;
|
|
2530
2725
|
}
|
|
2531
2726
|
/**
|
|
2532
|
-
*
|
|
2727
|
+
* Reads a projection as INLINE content - the view a link, an emphasis, and a table cell
|
|
2533
2728
|
* each need.
|
|
2534
2729
|
*
|
|
2535
2730
|
* @remarks
|
|
@@ -2549,14 +2744,14 @@ function projectionToBlocks(projection) {
|
|
|
2549
2744
|
*/
|
|
2550
2745
|
function projectionToInlines(projection) {
|
|
2551
2746
|
if (!(0, _orkestrel_contract.isNonEmptyArray)(projection.blocks) && !(0, _orkestrel_contract.isNonEmptyArray)(projection.cells) && !(0, _orkestrel_contract.isNonEmptyArray)(projection.rows)) return coalesceText(projection.inlines);
|
|
2552
|
-
const value = projectionToBlocks(projection).map(flattenText).join(" ")
|
|
2747
|
+
const value = (0, _orkestrel_html.collapseSpace)(projectionToBlocks(projection).map(flattenText).join(" "));
|
|
2553
2748
|
return (0, _orkestrel_contract.isEmptyString)(value) ? [] : [{
|
|
2554
2749
|
element: "text",
|
|
2555
2750
|
value
|
|
2556
2751
|
}];
|
|
2557
2752
|
}
|
|
2558
2753
|
/**
|
|
2559
|
-
*
|
|
2754
|
+
* Projects one HTML leaf - a text node, a comment, or a doctype - to its
|
|
2560
2755
|
* {@link MarkdownProjection}.
|
|
2561
2756
|
*
|
|
2562
2757
|
* @remarks
|
|
@@ -2586,7 +2781,7 @@ function projectHTMLLeaf(leaf) {
|
|
|
2586
2781
|
});
|
|
2587
2782
|
}
|
|
2588
2783
|
/**
|
|
2589
|
-
*
|
|
2784
|
+
* Projects one HTML container - the document root or an element - from its children's
|
|
2590
2785
|
* already-computed projections. THE element mapping, and the only place that decides
|
|
2591
2786
|
* what an HTML tag becomes in markdown.
|
|
2592
2787
|
*
|
|
@@ -2735,7 +2930,7 @@ function projectHTMLNode(node, children) {
|
|
|
2735
2930
|
text: merged.text
|
|
2736
2931
|
});
|
|
2737
2932
|
case "img": {
|
|
2738
|
-
const alt = ((0, _orkestrel_html.attributeOf)(node, "alt") ?? "")
|
|
2933
|
+
const alt = (0, _orkestrel_html.collapseSpace)((0, _orkestrel_html.attributeOf)(node, "alt") ?? "");
|
|
2739
2934
|
return createProjection({
|
|
2740
2935
|
inlines: [{
|
|
2741
2936
|
element: "image",
|
|
@@ -2893,7 +3088,7 @@ function projectHTMLNode(node, children) {
|
|
|
2893
3088
|
return merged;
|
|
2894
3089
|
}
|
|
2895
3090
|
/**
|
|
2896
|
-
*
|
|
3091
|
+
* Projects an `@orkestrel/html` {@link HTMLNode} into a {@link MarkdownDocument} - the
|
|
2897
3092
|
* HTML→markdown direction, and the inverse of {@link markdownToHTML}.
|
|
2898
3093
|
*
|
|
2899
3094
|
* @remarks
|
|
@@ -2949,7 +3144,7 @@ function htmlToMarkdown(node) {
|
|
|
2949
3144
|
};
|
|
2950
3145
|
}
|
|
2951
3146
|
/**
|
|
2952
|
-
*
|
|
3147
|
+
* Walks a {@link MarkdownNode} depth-first, pre-order, root-inclusive - yields
|
|
2953
3148
|
* the node itself, then recurses into its children (block children, list items,
|
|
2954
3149
|
* image/link inline children, table header/row cells' inline nodes) in walk order.
|
|
2955
3150
|
*
|
|
@@ -3012,7 +3207,7 @@ function* walkNodes(node) {
|
|
|
3012
3207
|
}
|
|
3013
3208
|
}
|
|
3014
3209
|
/**
|
|
3015
|
-
*
|
|
3210
|
+
* Folds a {@link MarkdownNode} into a `T` through a total catamorphism - children are
|
|
3016
3211
|
* folded first (post-order), then the node's own {@link MarkdownHandler} is invoked
|
|
3017
3212
|
* with the already-folded children.
|
|
3018
3213
|
*
|
|
@@ -3029,13 +3224,13 @@ function* walkNodes(node) {
|
|
|
3029
3224
|
* with an empty children list instead of recursing further.
|
|
3030
3225
|
*
|
|
3031
3226
|
* @param node - The AST node to fold
|
|
3032
|
-
* @param handlers - The total {@link
|
|
3227
|
+
* @param handlers - The total {@link MarkdownHandlerMap} table, one handler per element
|
|
3033
3228
|
* @param depth - The starting recursion depth (pass `0` at the entry point)
|
|
3034
3229
|
* @returns The folded `T`
|
|
3035
3230
|
*
|
|
3036
3231
|
* @example
|
|
3037
3232
|
* ```ts
|
|
3038
|
-
* const countHandlers:
|
|
3233
|
+
* const countHandlers: MarkdownHandlerMap<number> = {
|
|
3039
3234
|
* document: (_, children) => children.reduce((a, b) => a + b, 1),
|
|
3040
3235
|
* // ...one handler per element, each summing its folded children
|
|
3041
3236
|
* }
|
|
@@ -3164,19 +3359,20 @@ function foldNode(node, handlers, depth) {
|
|
|
3164
3359
|
}
|
|
3165
3360
|
}
|
|
3166
3361
|
/**
|
|
3167
|
-
*
|
|
3362
|
+
* Rewrites a {@link MarkdownDocument} bottom-up (copy-on-write) - each node's children
|
|
3168
3363
|
* are rewritten first (post-order), then `rewrite` is applied to the node itself; the
|
|
3169
3364
|
* document ROOT is never passed to `rewrite` (the `element: 'document'` invariant
|
|
3170
3365
|
* always holds). A table's inline cells and a list's items ARE rewritten.
|
|
3171
3366
|
*
|
|
3172
3367
|
* @remarks
|
|
3173
|
-
* Never mutates `document
|
|
3174
|
-
*
|
|
3175
|
-
*
|
|
3368
|
+
* Never mutates `document`. An unchanged subtree keeps its input identity. A parent
|
|
3369
|
+
* is rebuilt only when an accepted child changes, and the returned derivation map
|
|
3370
|
+
* associates each rebuilt output with its input node. When `rewrite` returns a node
|
|
3371
|
+
* whose `element` does not fit the slot it was called for (a block slot handed a
|
|
3176
3372
|
* non-{@link BlockNode}, an inline slot handed a non-{@link InlineNode}, a list-item
|
|
3177
|
-
* slot handed a non-`listItem`), the ill-fitting result is discarded and the
|
|
3178
|
-
*
|
|
3179
|
-
*
|
|
3373
|
+
* slot handed a non-`listItem`), the ill-fitting result is discarded and the accepted
|
|
3374
|
+
* input child is reused - `rewriteDocument` stays total and never produces a
|
|
3375
|
+
* structurally invalid document.
|
|
3180
3376
|
*
|
|
3181
3377
|
* Descent is capped at {@link MAX_DEPTH}, the same cap {@link walkNodes} and
|
|
3182
3378
|
* {@link foldNode} observe: at `depth >= MAX_DEPTH` the subtree is passed through
|
|
@@ -3186,11 +3382,11 @@ function foldNode(node, handlers, depth) {
|
|
|
3186
3382
|
*
|
|
3187
3383
|
* @param document - The document AST to rewrite
|
|
3188
3384
|
* @param rewrite - The bottom-up {@link MarkdownRewriteHandler}
|
|
3189
|
-
* @returns
|
|
3385
|
+
* @returns The rewritten document and its output-to-input derivations
|
|
3190
3386
|
*
|
|
3191
3387
|
* @example
|
|
3192
3388
|
* ```ts
|
|
3193
|
-
* rewriteDocument(document, (node) =>
|
|
3389
|
+
* const [rewritten, derivations] = rewriteDocument(document, (node) =>
|
|
3194
3390
|
* node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
|
|
3195
3391
|
* )
|
|
3196
3392
|
* ```
|
|
@@ -3203,6 +3399,7 @@ function rewriteDocument(document, rewrite) {
|
|
|
3203
3399
|
count: 0
|
|
3204
3400
|
}];
|
|
3205
3401
|
const values = [];
|
|
3402
|
+
const derivations = /* @__PURE__ */ new Map();
|
|
3206
3403
|
while (stack.length > 0) {
|
|
3207
3404
|
const frame = stack.pop();
|
|
3208
3405
|
if (frame === void 0) continue;
|
|
@@ -3256,6 +3453,7 @@ function rewriteDocument(document, rewrite) {
|
|
|
3256
3453
|
}
|
|
3257
3454
|
const children = frame.count === 0 ? [] : values.splice(values.length - frame.count, frame.count);
|
|
3258
3455
|
let rebuilt = current;
|
|
3456
|
+
let changed = false;
|
|
3259
3457
|
switch (current.element) {
|
|
3260
3458
|
case "document": {
|
|
3261
3459
|
const blocks = [];
|
|
@@ -3263,16 +3461,16 @@ function rewriteDocument(document, rewrite) {
|
|
|
3263
3461
|
for (const block of current.children) {
|
|
3264
3462
|
if (block === void 0) continue;
|
|
3265
3463
|
const child = children[offset];
|
|
3266
|
-
|
|
3464
|
+
const accepted = child !== void 0 && isBlockNode(child) ? child : block;
|
|
3465
|
+
blocks.push(accepted);
|
|
3466
|
+
if (accepted !== block) changed = true;
|
|
3267
3467
|
offset += 1;
|
|
3268
3468
|
}
|
|
3269
|
-
|
|
3469
|
+
if (changed) rebuilt = {
|
|
3270
3470
|
element: "document",
|
|
3271
3471
|
children: blocks
|
|
3272
3472
|
};
|
|
3273
|
-
|
|
3274
|
-
values.push(result);
|
|
3275
|
-
continue;
|
|
3473
|
+
break;
|
|
3276
3474
|
}
|
|
3277
3475
|
case "heading":
|
|
3278
3476
|
case "paragraph": {
|
|
@@ -3281,10 +3479,12 @@ function rewriteDocument(document, rewrite) {
|
|
|
3281
3479
|
for (const inline of current.children) {
|
|
3282
3480
|
if (inline === void 0) continue;
|
|
3283
3481
|
const child = children[offset];
|
|
3284
|
-
|
|
3482
|
+
const accepted = child !== void 0 && isInlineNode(child) ? child : inline;
|
|
3483
|
+
inlines.push(accepted);
|
|
3484
|
+
if (accepted !== inline) changed = true;
|
|
3285
3485
|
offset += 1;
|
|
3286
3486
|
}
|
|
3287
|
-
rebuilt = {
|
|
3487
|
+
if (changed) rebuilt = {
|
|
3288
3488
|
...current,
|
|
3289
3489
|
children: inlines
|
|
3290
3490
|
};
|
|
@@ -3296,10 +3496,12 @@ function rewriteDocument(document, rewrite) {
|
|
|
3296
3496
|
for (const block of current.children) {
|
|
3297
3497
|
if (block === void 0) continue;
|
|
3298
3498
|
const child = children[offset];
|
|
3299
|
-
|
|
3499
|
+
const accepted = child !== void 0 && isBlockNode(child) ? child : block;
|
|
3500
|
+
blocks.push(accepted);
|
|
3501
|
+
if (accepted !== block) changed = true;
|
|
3300
3502
|
offset += 1;
|
|
3301
3503
|
}
|
|
3302
|
-
rebuilt = {
|
|
3504
|
+
if (changed) rebuilt = {
|
|
3303
3505
|
...current,
|
|
3304
3506
|
children: blocks
|
|
3305
3507
|
};
|
|
@@ -3311,10 +3513,12 @@ function rewriteDocument(document, rewrite) {
|
|
|
3311
3513
|
for (const block of current.children) {
|
|
3312
3514
|
if (block === void 0) continue;
|
|
3313
3515
|
const child = children[offset];
|
|
3314
|
-
|
|
3516
|
+
const accepted = child !== void 0 && isBlockNode(child) ? child : block;
|
|
3517
|
+
blocks.push(accepted);
|
|
3518
|
+
if (accepted !== block) changed = true;
|
|
3315
3519
|
offset += 1;
|
|
3316
3520
|
}
|
|
3317
|
-
rebuilt = {
|
|
3521
|
+
if (changed) rebuilt = {
|
|
3318
3522
|
element: "listItem",
|
|
3319
3523
|
children: blocks
|
|
3320
3524
|
};
|
|
@@ -3328,10 +3532,12 @@ function rewriteDocument(document, rewrite) {
|
|
|
3328
3532
|
for (const inline of current.children) {
|
|
3329
3533
|
if (inline === void 0) continue;
|
|
3330
3534
|
const child = children[offset];
|
|
3331
|
-
|
|
3535
|
+
const accepted = child !== void 0 && isInlineNode(child) ? child : inline;
|
|
3536
|
+
inlines.push(accepted);
|
|
3537
|
+
if (accepted !== inline) changed = true;
|
|
3332
3538
|
offset += 1;
|
|
3333
3539
|
}
|
|
3334
|
-
rebuilt = {
|
|
3540
|
+
if (changed) rebuilt = {
|
|
3335
3541
|
...current,
|
|
3336
3542
|
children: inlines
|
|
3337
3543
|
};
|
|
@@ -3343,10 +3549,12 @@ function rewriteDocument(document, rewrite) {
|
|
|
3343
3549
|
for (const item of current.items) {
|
|
3344
3550
|
if (item === void 0) continue;
|
|
3345
3551
|
const child = children[offset];
|
|
3346
|
-
|
|
3552
|
+
const accepted = child?.element === "listItem" ? child : item;
|
|
3553
|
+
items.push(accepted);
|
|
3554
|
+
if (accepted !== item) changed = true;
|
|
3347
3555
|
offset += 1;
|
|
3348
3556
|
}
|
|
3349
|
-
rebuilt = {
|
|
3557
|
+
if (changed) rebuilt = {
|
|
3350
3558
|
...current,
|
|
3351
3559
|
items
|
|
3352
3560
|
};
|
|
@@ -3361,7 +3569,9 @@ function rewriteDocument(document, rewrite) {
|
|
|
3361
3569
|
for (const inline of cell) {
|
|
3362
3570
|
if (inline === void 0) continue;
|
|
3363
3571
|
const child = children[offset];
|
|
3364
|
-
|
|
3572
|
+
const accepted = child !== void 0 && isInlineNode(child) ? child : inline;
|
|
3573
|
+
inlines.push(accepted);
|
|
3574
|
+
if (accepted !== inline) changed = true;
|
|
3365
3575
|
offset += 1;
|
|
3366
3576
|
}
|
|
3367
3577
|
header.push(inlines);
|
|
@@ -3376,14 +3586,16 @@ function rewriteDocument(document, rewrite) {
|
|
|
3376
3586
|
for (const inline of cell) {
|
|
3377
3587
|
if (inline === void 0) continue;
|
|
3378
3588
|
const child = children[offset];
|
|
3379
|
-
|
|
3589
|
+
const accepted = child !== void 0 && isInlineNode(child) ? child : inline;
|
|
3590
|
+
inlines.push(accepted);
|
|
3591
|
+
if (accepted !== inline) changed = true;
|
|
3380
3592
|
offset += 1;
|
|
3381
3593
|
}
|
|
3382
3594
|
cells.push(inlines);
|
|
3383
3595
|
}
|
|
3384
3596
|
rows.push(cells);
|
|
3385
3597
|
}
|
|
3386
|
-
rebuilt = {
|
|
3598
|
+
if (changed) rebuilt = {
|
|
3387
3599
|
...current,
|
|
3388
3600
|
header,
|
|
3389
3601
|
rows
|
|
@@ -3391,6 +3603,14 @@ function rewriteDocument(document, rewrite) {
|
|
|
3391
3603
|
break;
|
|
3392
3604
|
}
|
|
3393
3605
|
}
|
|
3606
|
+
if (rebuilt !== current) derivations.set(rebuilt, current);
|
|
3607
|
+
if (current.element === "document") {
|
|
3608
|
+
const result = rebuilt.element === "document" ? rebuilt : current;
|
|
3609
|
+
const output = new Set(walkNodes(result));
|
|
3610
|
+
const retained = /* @__PURE__ */ new Map();
|
|
3611
|
+
for (const [node, source] of derivations) if (output.has(node)) retained.set(node, source);
|
|
3612
|
+
return [result, retained];
|
|
3613
|
+
}
|
|
3394
3614
|
const result = rewrite(rebuilt);
|
|
3395
3615
|
let accepted = rebuilt;
|
|
3396
3616
|
switch (current.element) {
|
|
@@ -3413,15 +3633,16 @@ function rewriteDocument(document, rewrite) {
|
|
|
3413
3633
|
break;
|
|
3414
3634
|
case "listItem": if (result.element === "listItem") accepted = result;
|
|
3415
3635
|
}
|
|
3636
|
+
if (accepted !== rebuilt && accepted !== current) {
|
|
3637
|
+
if (derivations.has(accepted) && derivations.get(accepted) !== current) derivations.set(accepted, void 0);
|
|
3638
|
+
else derivations.set(accepted, current);
|
|
3639
|
+
}
|
|
3416
3640
|
values.push(accepted);
|
|
3417
3641
|
}
|
|
3418
|
-
return
|
|
3419
|
-
element: "document",
|
|
3420
|
-
children: [...document.children]
|
|
3421
|
-
};
|
|
3642
|
+
return [document, /* @__PURE__ */ new Map()];
|
|
3422
3643
|
}
|
|
3423
3644
|
/**
|
|
3424
|
-
*
|
|
3645
|
+
* Concatenates the `value` / `code` content of every descendant text / code-span /
|
|
3425
3646
|
* code-block node under `node`, including image alternative content, in walk order -
|
|
3426
3647
|
* the plain-text projection of an AST (search indexing, word counts, a text-only
|
|
3427
3648
|
* preview).
|
|
@@ -3494,6 +3715,458 @@ function flattenText(node) {
|
|
|
3494
3715
|
return value;
|
|
3495
3716
|
}
|
|
3496
3717
|
//#endregion
|
|
3718
|
+
//#region src/core/compilers.ts
|
|
3719
|
+
/**
|
|
3720
|
+
* Renders a {@link MarkdownNode} to sanitized canonical HTML.
|
|
3721
|
+
*
|
|
3722
|
+
* @remarks
|
|
3723
|
+
* Markdown widens `@orkestrel/html`'s attribute floor by exactly `src`, because image
|
|
3724
|
+
* syntax is meaningless without its source. `src` is still a URL attribute, so the
|
|
3725
|
+
* floor refuses `javascript:`, `data:`, `vbscript:`, and `file:` values. A stricter
|
|
3726
|
+
* consumer can compose {@link markdownToHTML} with `@orkestrel/html`'s `HTML` class
|
|
3727
|
+
* directly.
|
|
3728
|
+
*
|
|
3729
|
+
* @param node - The markdown document or bare node to render
|
|
3730
|
+
* @returns Sanitized canonical HTML
|
|
3731
|
+
*
|
|
3732
|
+
* @example
|
|
3733
|
+
* ```ts
|
|
3734
|
+
* renderHTML({ element: 'paragraph', children: [{ element: 'text', value: 'a & b' }] })
|
|
3735
|
+
* // '<p>a & b</p>'
|
|
3736
|
+
* ```
|
|
3737
|
+
*/
|
|
3738
|
+
function renderHTML(node) {
|
|
3739
|
+
return (0, _orkestrel_html.renderHTML)(new _orkestrel_html.HTML(markdownToHTML(node)).sanitize({ attributes: [..._orkestrel_html.SAFE_ATTRIBUTES, "src"] }).document);
|
|
3740
|
+
}
|
|
3741
|
+
//#endregion
|
|
3742
|
+
//#region src/core/shapers.ts
|
|
3743
|
+
/**
|
|
3744
|
+
* Describes the shape of a {@link TextNode} - a plain-text leaf inline run.
|
|
3745
|
+
*
|
|
3746
|
+
* @example
|
|
3747
|
+
* ```ts
|
|
3748
|
+
* import { createContract } from '@orkestrel/contract'
|
|
3749
|
+
* import { textShape } from '@src/core'
|
|
3750
|
+
*
|
|
3751
|
+
* const text = createContract(textShape)
|
|
3752
|
+
* text.is({ element: 'text', value: 'hi' }) // true
|
|
3753
|
+
* ```
|
|
3754
|
+
*/
|
|
3755
|
+
var textShape = (0, _orkestrel_contract.objectShape)({
|
|
3756
|
+
element: (0, _orkestrel_contract.literalShape)(["text"]),
|
|
3757
|
+
value: (0, _orkestrel_contract.stringShape)()
|
|
3758
|
+
});
|
|
3759
|
+
/**
|
|
3760
|
+
* Describes the shape of a {@link CodeSpanNode} - an inline code span (`` `code` ``).
|
|
3761
|
+
*
|
|
3762
|
+
* @example
|
|
3763
|
+
* ```ts
|
|
3764
|
+
* import { createContract } from '@orkestrel/contract'
|
|
3765
|
+
* import { codeSpanShape } from '@src/core'
|
|
3766
|
+
*
|
|
3767
|
+
* const codeSpan = createContract(codeSpanShape)
|
|
3768
|
+
* codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
|
|
3769
|
+
* ```
|
|
3770
|
+
*/
|
|
3771
|
+
var codeSpanShape = (0, _orkestrel_contract.objectShape)({
|
|
3772
|
+
element: (0, _orkestrel_contract.literalShape)(["codeSpan"]),
|
|
3773
|
+
value: (0, _orkestrel_contract.stringShape)()
|
|
3774
|
+
});
|
|
3775
|
+
/**
|
|
3776
|
+
* Describes the shape of a {@link LineBreakNode} - a GFM hard line-break leaf.
|
|
3777
|
+
*
|
|
3778
|
+
* @example
|
|
3779
|
+
* ```ts
|
|
3780
|
+
* import { createContract } from '@orkestrel/contract'
|
|
3781
|
+
* import { lineBreakShape } from '@src/core'
|
|
3782
|
+
*
|
|
3783
|
+
* const lineBreak = createContract(lineBreakShape)
|
|
3784
|
+
* lineBreak.is({ element: 'break' }) // true
|
|
3785
|
+
* ```
|
|
3786
|
+
*/
|
|
3787
|
+
var lineBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["break"]) });
|
|
3788
|
+
/**
|
|
3789
|
+
* Describes the shape of a {@link CodeBlockNode} - a fenced code block. `lang` is
|
|
3790
|
+
* optional (absent when the opening fence carries no info-string).
|
|
3791
|
+
*
|
|
3792
|
+
* @example
|
|
3793
|
+
* ```ts
|
|
3794
|
+
* import { createContract } from '@orkestrel/contract'
|
|
3795
|
+
* import { codeBlockShape } from '@src/core'
|
|
3796
|
+
*
|
|
3797
|
+
* const codeBlock = createContract(codeBlockShape)
|
|
3798
|
+
* codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
|
|
3799
|
+
* codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
|
|
3800
|
+
* ```
|
|
3801
|
+
*/
|
|
3802
|
+
var codeBlockShape = (0, _orkestrel_contract.objectShape)({
|
|
3803
|
+
element: (0, _orkestrel_contract.literalShape)(["codeBlock"]),
|
|
3804
|
+
lang: (0, _orkestrel_contract.optionalShape)((0, _orkestrel_contract.stringShape)()),
|
|
3805
|
+
code: (0, _orkestrel_contract.stringShape)()
|
|
3806
|
+
});
|
|
3807
|
+
/**
|
|
3808
|
+
* Describes the shape of a {@link ThematicBreakNode} - a horizontal rule. Carries no
|
|
3809
|
+
* fields beyond its `element` discriminant.
|
|
3810
|
+
*
|
|
3811
|
+
* @example
|
|
3812
|
+
* ```ts
|
|
3813
|
+
* import { createContract } from '@orkestrel/contract'
|
|
3814
|
+
* import { thematicBreakShape } from '@src/core'
|
|
3815
|
+
*
|
|
3816
|
+
* const thematicBreak = createContract(thematicBreakShape)
|
|
3817
|
+
* thematicBreak.is({ element: 'thematicBreak' }) // true
|
|
3818
|
+
* ```
|
|
3819
|
+
*/
|
|
3820
|
+
var thematicBreakShape = (0, _orkestrel_contract.objectShape)({ element: (0, _orkestrel_contract.literalShape)(["thematicBreak"]) });
|
|
3821
|
+
/**
|
|
3822
|
+
* Describes the shape of a {@link TableAlign} - the per-column GFM table alignment
|
|
3823
|
+
* literal.
|
|
3824
|
+
*
|
|
3825
|
+
* @example
|
|
3826
|
+
* ```ts
|
|
3827
|
+
* import { createContract } from '@orkestrel/contract'
|
|
3828
|
+
* import { tableAlignShape } from '@src/core'
|
|
3829
|
+
*
|
|
3830
|
+
* const tableAlign = createContract(tableAlignShape)
|
|
3831
|
+
* tableAlign.is('left') // true
|
|
3832
|
+
* tableAlign.is('center') // true
|
|
3833
|
+
* tableAlign.is('top') // false
|
|
3834
|
+
* ```
|
|
3835
|
+
*/
|
|
3836
|
+
var tableAlignShape = (0, _orkestrel_contract.literalShape)([
|
|
3837
|
+
"left",
|
|
3838
|
+
"right",
|
|
3839
|
+
"center"
|
|
3840
|
+
]);
|
|
3841
|
+
/**
|
|
3842
|
+
* Describes the shape of {@link ListItemMatch} - the parsed parts of a single list-item
|
|
3843
|
+
* line the block phase's list detector returns. Fully non-recursive (no
|
|
3844
|
+
* nested node fields), so every field shapes directly.
|
|
3845
|
+
*
|
|
3846
|
+
* @example
|
|
3847
|
+
* ```ts
|
|
3848
|
+
* import { createContract } from '@orkestrel/contract'
|
|
3849
|
+
* import { listItemMatchShape } from '@src/core'
|
|
3850
|
+
*
|
|
3851
|
+
* const listItemParts = createContract(listItemMatchShape)
|
|
3852
|
+
* listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
|
|
3853
|
+
* ```
|
|
3854
|
+
*/
|
|
3855
|
+
var listItemMatchShape = (0, _orkestrel_contract.objectShape)({
|
|
3856
|
+
ordered: (0, _orkestrel_contract.booleanShape)(),
|
|
3857
|
+
start: (0, _orkestrel_contract.integerShape)(),
|
|
3858
|
+
content: (0, _orkestrel_contract.stringShape)(),
|
|
3859
|
+
indent: (0, _orkestrel_contract.integerShape)(),
|
|
3860
|
+
marker: (0, _orkestrel_contract.integerShape)()
|
|
3861
|
+
});
|
|
3862
|
+
//#endregion
|
|
3863
|
+
//#region src/core/Markdown.ts
|
|
3864
|
+
/**
|
|
3865
|
+
* Wraps a typed {@link MarkdownDocument} AST as a stateful, parsed markdown document
|
|
3866
|
+
* with the query (`find` / `filter` / `reduce` / iteration), rewrite (`map`), fold, and
|
|
3867
|
+
* streaming operations {@link MarkdownInterface} declares.
|
|
3868
|
+
*
|
|
3869
|
+
* @remarks
|
|
3870
|
+
* - **Construction.** Given a `string`, the constructor runs {@link parseProvenance} (the
|
|
3871
|
+
* block phase then the inline phase) once, keeping the AST and a COPY of the span map
|
|
3872
|
+
* that parse recorded. Given a {@link MarkdownDocument}, the document is adopted AS-IS
|
|
3873
|
+
* and is NOT re-validated - gate an untrusted value with `isMarkdownDocument` first.
|
|
3874
|
+
* - **Provenance.** {@link span} reads the region of the ORIGINAL constructor string a
|
|
3875
|
+
* node was produced from, and it is handle-relative: a string-constructed handle exposes
|
|
3876
|
+
* the regions of the nodes it parsed, an adopted document exposes none, and a node from
|
|
3877
|
+
* another handle reports `undefined` here whatever that handle reports. Each call
|
|
3878
|
+
* returns a fresh value. A node reports the region THIS handle holds for its identity,
|
|
3879
|
+
* else the region of the direct input a rewrite named for it, else `undefined`: a text
|
|
3880
|
+
* run the parse joined from adjacent scanner output reports the region enclosing its
|
|
3881
|
+
* parts, and only a rewrite output that holds no region of its own and was assembled
|
|
3882
|
+
* from separate source nodes reports `undefined`.
|
|
3883
|
+
* {@link map} carries provenance across the rewrite: an unchanged node keeps its
|
|
3884
|
+
* region, a one-source replacement takes the region of the node it replaced, and a
|
|
3885
|
+
* rebuilt parent takes its original's.
|
|
3886
|
+
* - **Immutable.** {@link map} never mutates the stored AST - it returns a NEW `Markdown`
|
|
3887
|
+
* instance; the document root invariant (`element: 'document'`) always holds. An
|
|
3888
|
+
* identity rewrite still returns a new handle, over the same document tree.
|
|
3889
|
+
* - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
|
|
3890
|
+
* on it walk the AST depth-first, pre-order, root-inclusive (through {@link walkNodes});
|
|
3891
|
+
* `stream` is shallow - only the document's direct block children.
|
|
3892
|
+
*
|
|
3893
|
+
* @example
|
|
3894
|
+
* ```ts
|
|
3895
|
+
* import { Markdown, isHeadingNode, renderMarkdown } from '@src/core'
|
|
3896
|
+
*
|
|
3897
|
+
* const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
|
|
3898
|
+
* const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
|
|
3899
|
+
* const shouted = markdown.map((node) =>
|
|
3900
|
+
* node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
|
|
3901
|
+
* )
|
|
3902
|
+
* renderMarkdown(shouted.document) // '# TITLE\n\nA **BOLD** [LINK](https://x.dev).'
|
|
3903
|
+
* ```
|
|
3904
|
+
*/
|
|
3905
|
+
var Markdown = class Markdown {
|
|
3906
|
+
#document;
|
|
3907
|
+
#spans;
|
|
3908
|
+
constructor(input) {
|
|
3909
|
+
if (typeof input === "string") {
|
|
3910
|
+
const [document, spans] = parseProvenance(input);
|
|
3911
|
+
this.#document = document;
|
|
3912
|
+
this.#spans = new Map(spans);
|
|
3913
|
+
} else {
|
|
3914
|
+
this.#document = input;
|
|
3915
|
+
this.#spans = /* @__PURE__ */ new Map();
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
/** Holds the stored {@link MarkdownDocument} AST root. */
|
|
3919
|
+
get document() {
|
|
3920
|
+
return this.#document;
|
|
3921
|
+
}
|
|
3922
|
+
/**
|
|
3923
|
+
* Reads the region of the original markdown string a node of this handle's tree was
|
|
3924
|
+
* produced from.
|
|
3925
|
+
*
|
|
3926
|
+
* @param node - The node whose provenance to read
|
|
3927
|
+
* @returns A fresh {@link MarkdownSpan}, or `undefined` when this handle holds no
|
|
3928
|
+
* region for the node
|
|
3929
|
+
*
|
|
3930
|
+
* @example
|
|
3931
|
+
* ```ts
|
|
3932
|
+
* const source = '# Title\n\npara'
|
|
3933
|
+
* const markdown = new Markdown(source)
|
|
3934
|
+
* const heading = markdown.find(isHeadingNode)
|
|
3935
|
+
* const span = heading && markdown.span(heading)
|
|
3936
|
+
* span && source.slice(span.start, span.end) // '# Title'
|
|
3937
|
+
* ```
|
|
3938
|
+
*/
|
|
3939
|
+
span(node) {
|
|
3940
|
+
const span = this.#spans.get(node);
|
|
3941
|
+
return span === void 0 ? void 0 : {
|
|
3942
|
+
start: span.start,
|
|
3943
|
+
end: span.end
|
|
3944
|
+
};
|
|
3945
|
+
}
|
|
3946
|
+
/**
|
|
3947
|
+
* Returns THE deep traversal - a lazy, depth-first, pre-order, root-inclusive generator
|
|
3948
|
+
* over every {@link MarkdownNode} in the document. `find` / `filter` / `reduce`
|
|
3949
|
+
* all iterate this single traversal.
|
|
3950
|
+
*
|
|
3951
|
+
* @example
|
|
3952
|
+
* ```ts
|
|
3953
|
+
* for (const node of markdown.walk()) {
|
|
3954
|
+
* // every node, depth-first, pre-order, root-inclusive
|
|
3955
|
+
* }
|
|
3956
|
+
*
|
|
3957
|
+
* // also consumable by for-await - JS accepts a sync iterable in for-await
|
|
3958
|
+
* for await (const node of markdown.walk()) {
|
|
3959
|
+
* // same sequence, no separate async iterator needed
|
|
3960
|
+
* }
|
|
3961
|
+
* ```
|
|
3962
|
+
*/
|
|
3963
|
+
*walk() {
|
|
3964
|
+
yield* walkNodes(this.#document);
|
|
3965
|
+
}
|
|
3966
|
+
find(predicate) {
|
|
3967
|
+
for (const node of this.walk()) if (predicate(node)) return node;
|
|
3968
|
+
}
|
|
3969
|
+
filter(predicate) {
|
|
3970
|
+
const out = [];
|
|
3971
|
+
for (const node of this.walk()) if (predicate(node)) out.push(node);
|
|
3972
|
+
return out;
|
|
3973
|
+
}
|
|
3974
|
+
/**
|
|
3975
|
+
* Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown},
|
|
3976
|
+
* carrying each output node's provenance across the rewrite. A rewrite that returns
|
|
3977
|
+
* its node unchanged shares that subtree instead of copying it, so an identity
|
|
3978
|
+
* rewrite copies no node and still returns a new handle.
|
|
3979
|
+
*
|
|
3980
|
+
* @param rewrite - The bottom-up node rewrite
|
|
3981
|
+
* @returns A new handle over the rewritten document
|
|
3982
|
+
*/
|
|
3983
|
+
map(rewrite) {
|
|
3984
|
+
const [document, derivations] = rewriteDocument(this.#document, rewrite);
|
|
3985
|
+
return this.#derive(document, derivations);
|
|
3986
|
+
}
|
|
3987
|
+
/** Folds the AST depth-first, pre-order into an accumulator. */
|
|
3988
|
+
reduce(callback, initial) {
|
|
3989
|
+
let accumulator = initial;
|
|
3990
|
+
for (const node of this.walk()) accumulator = callback(accumulator, node);
|
|
3991
|
+
return accumulator;
|
|
3992
|
+
}
|
|
3993
|
+
/** Runs a total catamorphism over the document using a {@link MarkdownHandlerMap} table. */
|
|
3994
|
+
fold(handlers) {
|
|
3995
|
+
return foldNode(this.#document, handlers, 0);
|
|
3996
|
+
}
|
|
3997
|
+
/**
|
|
3998
|
+
* Returns a web-standard {@link ReadableStream} over the document's top-level block nodes
|
|
3999
|
+
* (shallow, source order) - a fresh, pull-based source per call: one block is
|
|
4000
|
+
* enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
|
|
4001
|
+
* async-iterable wherever the platform supports it (Node, Deno), and pipeable
|
|
4002
|
+
* through any {@link TransformStream} / {@link WritableStream}.
|
|
4003
|
+
*
|
|
4004
|
+
* @example
|
|
4005
|
+
* ```ts
|
|
4006
|
+
* // universal - works in every ReadableStream-supporting environment
|
|
4007
|
+
* const reader = markdown.stream().getReader()
|
|
4008
|
+
* for (let result = await reader.read(); !result.done; result = await reader.read()) {
|
|
4009
|
+
* console.log(result.value) // one BlockNode
|
|
4010
|
+
* }
|
|
4011
|
+
*
|
|
4012
|
+
* // Node / Deno / Firefox support async iteration of ReadableStream natively;
|
|
4013
|
+
* // other environments use the reader loop shown earlier.
|
|
4014
|
+
* for await (const block of markdown.stream()) {
|
|
4015
|
+
* console.log(block)
|
|
4016
|
+
* }
|
|
4017
|
+
* ```
|
|
4018
|
+
*/
|
|
4019
|
+
stream() {
|
|
4020
|
+
const blocks = this.#document.children;
|
|
4021
|
+
let index = 0;
|
|
4022
|
+
return new ReadableStream({ pull(controller) {
|
|
4023
|
+
if (index < blocks.length) {
|
|
4024
|
+
const block = blocks[index];
|
|
4025
|
+
if (block === void 0) {
|
|
4026
|
+
controller.close();
|
|
4027
|
+
return;
|
|
4028
|
+
}
|
|
4029
|
+
controller.enqueue(block);
|
|
4030
|
+
index += 1;
|
|
4031
|
+
} else controller.close();
|
|
4032
|
+
} });
|
|
4033
|
+
}
|
|
4034
|
+
#derive(document, derivations) {
|
|
4035
|
+
const derived = new Markdown(document);
|
|
4036
|
+
for (const node of walkNodes(document)) {
|
|
4037
|
+
const own = this.#spans.get(node);
|
|
4038
|
+
if (own !== void 0) {
|
|
4039
|
+
derived.#spans.set(node, own);
|
|
4040
|
+
continue;
|
|
4041
|
+
}
|
|
4042
|
+
const source = derivations.get(node);
|
|
4043
|
+
if (source === void 0) continue;
|
|
4044
|
+
const span = this.#spans.get(source);
|
|
4045
|
+
if (span !== void 0) derived.#spans.set(node, span);
|
|
4046
|
+
}
|
|
4047
|
+
return derived;
|
|
4048
|
+
}
|
|
4049
|
+
};
|
|
4050
|
+
//#endregion
|
|
4051
|
+
//#region src/core/factories.ts
|
|
4052
|
+
/**
|
|
4053
|
+
* Creates a stateful markdown handle from a markdown string or an already-parsed
|
|
4054
|
+
* {@link MarkdownDocument} - a typed AST plus the query, rewrite, and fold operations
|
|
4055
|
+
* {@link MarkdownInterface} exposes.
|
|
4056
|
+
*
|
|
4057
|
+
* @remarks
|
|
4058
|
+
* Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
|
|
4059
|
+
* fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
|
|
4060
|
+
* inline code / links / images / hard breaks) to build a render-agnostic
|
|
4061
|
+
* {@link MarkdownDocument}. Given a
|
|
4062
|
+
* {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
|
|
4063
|
+
* value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
|
|
4064
|
+
* degrades to text, never throws) and zero-dependency - a hand-written scanner, no
|
|
4065
|
+
* regex-only structural parse, linear-time (no ReDoS).
|
|
4066
|
+
*
|
|
4067
|
+
* @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
|
|
4068
|
+
* @returns A working {@link MarkdownInterface}
|
|
4069
|
+
*
|
|
4070
|
+
* @example
|
|
4071
|
+
* ```ts
|
|
4072
|
+
* import { createMarkdown } from '@src/core'
|
|
4073
|
+
*
|
|
4074
|
+
* const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
|
|
4075
|
+
* markdown.document.children[0] // { element: 'heading', ... }
|
|
4076
|
+
* ```
|
|
4077
|
+
*/
|
|
4078
|
+
function createMarkdown(input) {
|
|
4079
|
+
return new Markdown(input);
|
|
4080
|
+
}
|
|
4081
|
+
/**
|
|
4082
|
+
* Compiles the {@link textShape} into a {@link ContractInterface} for
|
|
4083
|
+
* {@link TextNode} - a guard, coercing parser, JSON Schema, and seeded
|
|
4084
|
+
* generator from one shape declaration.
|
|
4085
|
+
*
|
|
4086
|
+
* @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
4087
|
+
*
|
|
4088
|
+
* @example
|
|
4089
|
+
* ```ts
|
|
4090
|
+
* import { createTextContract } from '@src/core'
|
|
4091
|
+
*
|
|
4092
|
+
* const text = createTextContract()
|
|
4093
|
+
* text.is({ element: 'text', value: 'hi' }) // true
|
|
4094
|
+
* ```
|
|
4095
|
+
*/
|
|
4096
|
+
function createTextContract() {
|
|
4097
|
+
return (0, _orkestrel_contract.createContract)(textShape);
|
|
4098
|
+
}
|
|
4099
|
+
/**
|
|
4100
|
+
* Compiles the {@link codeSpanShape} into a {@link ContractInterface} for
|
|
4101
|
+
* {@link CodeSpanNode} - a guard, coercing parser, JSON Schema, and seeded
|
|
4102
|
+
* generator from one shape declaration.
|
|
4103
|
+
*
|
|
4104
|
+
* @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
4105
|
+
*
|
|
4106
|
+
* @example
|
|
4107
|
+
* ```ts
|
|
4108
|
+
* import { createCodeSpanContract } from '@src/core'
|
|
4109
|
+
*
|
|
4110
|
+
* const codeSpan = createCodeSpanContract()
|
|
4111
|
+
* codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
|
|
4112
|
+
* ```
|
|
4113
|
+
*/
|
|
4114
|
+
function createCodeSpanContract() {
|
|
4115
|
+
return (0, _orkestrel_contract.createContract)(codeSpanShape);
|
|
4116
|
+
}
|
|
4117
|
+
/**
|
|
4118
|
+
* Compiles the {@link lineBreakShape} into a {@link ContractInterface} for
|
|
4119
|
+
* {@link LineBreakNode}.
|
|
4120
|
+
*
|
|
4121
|
+
* @returns A `LineBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
4122
|
+
*
|
|
4123
|
+
* @example
|
|
4124
|
+
* ```ts
|
|
4125
|
+
* import { createLineBreakContract } from '@src/core'
|
|
4126
|
+
*
|
|
4127
|
+
* createLineBreakContract().is({ element: 'break' }) // true
|
|
4128
|
+
* ```
|
|
4129
|
+
*/
|
|
4130
|
+
function createLineBreakContract() {
|
|
4131
|
+
return (0, _orkestrel_contract.createContract)(lineBreakShape);
|
|
4132
|
+
}
|
|
4133
|
+
/**
|
|
4134
|
+
* Compiles the {@link codeBlockShape} into a {@link ContractInterface} for
|
|
4135
|
+
* {@link CodeBlockNode} - a guard, coercing parser, JSON Schema, and seeded
|
|
4136
|
+
* generator from one shape declaration.
|
|
4137
|
+
*
|
|
4138
|
+
* @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
4139
|
+
*
|
|
4140
|
+
* @example
|
|
4141
|
+
* ```ts
|
|
4142
|
+
* import { createCodeBlockContract } from '@src/core'
|
|
4143
|
+
*
|
|
4144
|
+
* const codeBlock = createCodeBlockContract()
|
|
4145
|
+
* codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
|
|
4146
|
+
* ```
|
|
4147
|
+
*/
|
|
4148
|
+
function createCodeBlockContract() {
|
|
4149
|
+
return (0, _orkestrel_contract.createContract)(codeBlockShape);
|
|
4150
|
+
}
|
|
4151
|
+
/**
|
|
4152
|
+
* Compiles the {@link thematicBreakShape} into a {@link ContractInterface} for
|
|
4153
|
+
* {@link ThematicBreakNode} - a guard, coercing parser, JSON Schema, and
|
|
4154
|
+
* seeded generator from one shape declaration.
|
|
4155
|
+
*
|
|
4156
|
+
* @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
|
|
4157
|
+
*
|
|
4158
|
+
* @example
|
|
4159
|
+
* ```ts
|
|
4160
|
+
* import { createThematicBreakContract } from '@src/core'
|
|
4161
|
+
*
|
|
4162
|
+
* const thematicBreak = createThematicBreakContract()
|
|
4163
|
+
* thematicBreak.is({ element: 'thematicBreak' }) // true
|
|
4164
|
+
* ```
|
|
4165
|
+
*/
|
|
4166
|
+
function createThematicBreakContract() {
|
|
4167
|
+
return (0, _orkestrel_contract.createContract)(thematicBreakShape);
|
|
4168
|
+
}
|
|
4169
|
+
//#endregion
|
|
3497
4170
|
exports.EMPTY_PROJECTION = EMPTY_PROJECTION;
|
|
3498
4171
|
exports.MAX_DEPTH = MAX_DEPTH;
|
|
3499
4172
|
exports.Markdown = Markdown;
|
|
@@ -3526,6 +4199,7 @@ exports.isEmphasisNode = isEmphasisNode;
|
|
|
3526
4199
|
exports.isEscapable = isEscapable;
|
|
3527
4200
|
exports.isFenceClose = isFenceClose;
|
|
3528
4201
|
exports.isFenceWhitespace = isFenceWhitespace;
|
|
4202
|
+
exports.isFlankingWhitespace = isFlankingWhitespace;
|
|
3529
4203
|
exports.isHeadingNode = isHeadingNode;
|
|
3530
4204
|
exports.isImageNode = isImageNode;
|
|
3531
4205
|
exports.isInlineNode = isInlineNode;
|
|
@@ -3541,17 +4215,22 @@ exports.isTableStart = isTableStart;
|
|
|
3541
4215
|
exports.isTextNode = isTextNode;
|
|
3542
4216
|
exports.isThematicBreak = isThematicBreak;
|
|
3543
4217
|
exports.isThematicBreakNode = isThematicBreakNode;
|
|
3544
|
-
exports.
|
|
4218
|
+
exports.joinSources = joinSources;
|
|
3545
4219
|
exports.lineBreakShape = lineBreakShape;
|
|
3546
4220
|
exports.listItemMatchShape = listItemMatchShape;
|
|
4221
|
+
exports.locateEmphasis = locateEmphasis;
|
|
4222
|
+
exports.locateLink = locateLink;
|
|
3547
4223
|
exports.markdownToHTML = markdownToHTML;
|
|
3548
4224
|
exports.mergeProjections = mergeProjections;
|
|
3549
4225
|
exports.normalizeInlines = normalizeInlines;
|
|
4226
|
+
exports.normalizeParagraphLine = normalizeParagraphLine;
|
|
3550
4227
|
exports.parseBlocks = parseBlocks;
|
|
3551
4228
|
exports.parseDocument = parseDocument;
|
|
3552
4229
|
exports.parseInline = parseInline;
|
|
4230
|
+
exports.parseProvenance = parseProvenance;
|
|
3553
4231
|
exports.projectHTMLLeaf = projectHTMLLeaf;
|
|
3554
4232
|
exports.projectHTMLNode = projectHTMLNode;
|
|
4233
|
+
exports.projectSpan = projectSpan;
|
|
3555
4234
|
exports.projectionToBlocks = projectionToBlocks;
|
|
3556
4235
|
exports.projectionToInlines = projectionToInlines;
|
|
3557
4236
|
exports.renderHTML = renderHTML;
|
|
@@ -3560,15 +4239,19 @@ exports.rewriteDocument = rewriteDocument;
|
|
|
3560
4239
|
exports.scanCode = scanCode;
|
|
3561
4240
|
exports.scanEmphasis = scanEmphasis;
|
|
3562
4241
|
exports.scanInline = scanInline;
|
|
4242
|
+
exports.scanInlineSource = scanInlineSource;
|
|
3563
4243
|
exports.scanLink = scanLink;
|
|
4244
|
+
exports.sliceSource = sliceSource;
|
|
3564
4245
|
exports.splitLines = splitLines;
|
|
3565
4246
|
exports.splitTableRow = splitTableRow;
|
|
4247
|
+
exports.splitTableSources = splitTableSources;
|
|
3566
4248
|
exports.startsBlock = startsBlock;
|
|
3567
4249
|
exports.stripQuote = stripQuote;
|
|
3568
4250
|
exports.tableAlignShape = tableAlignShape;
|
|
3569
4251
|
exports.textShape = textShape;
|
|
3570
4252
|
exports.thematicBreakShape = thematicBreakShape;
|
|
3571
4253
|
exports.trimInlines = trimInlines;
|
|
4254
|
+
exports.trimSource = trimSource;
|
|
3572
4255
|
exports.unescapeText = unescapeText;
|
|
3573
4256
|
exports.walkNodes = walkNodes;
|
|
3574
4257
|
|