@orkestrel/markdown 0.0.12 → 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 +920 -839
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +392 -238
- package/dist/src/core/index.d.ts +392 -238
- package/dist/src/core/index.js +921 -840
- 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
|
|
@@ -596,11 +511,16 @@ function parseBlocks(lines, depth, spans = /* @__PURE__ */ new Map(), end) {
|
|
|
596
511
|
return blocks;
|
|
597
512
|
}
|
|
598
513
|
/**
|
|
599
|
-
* Parses a markdown string into a typed {@link MarkdownDocument} AST
|
|
514
|
+
* Parses a markdown string into a typed {@link MarkdownDocument} AST through the
|
|
600
515
|
* block phase.
|
|
601
516
|
*
|
|
602
517
|
* @param markdown - The markdown source to parse.
|
|
603
518
|
* @returns The parsed document.
|
|
519
|
+
*
|
|
520
|
+
* @example
|
|
521
|
+
* ```ts
|
|
522
|
+
* parseDocument('# Hi') // { element: 'document', children: [{ element: 'heading', ... }] }
|
|
523
|
+
* ```
|
|
604
524
|
*/
|
|
605
525
|
function parseDocument(markdown) {
|
|
606
526
|
const [document] = parseProvenance(markdown);
|
|
@@ -611,6 +531,12 @@ function parseDocument(markdown) {
|
|
|
611
531
|
*
|
|
612
532
|
* @param markdown - The markdown source to parse.
|
|
613
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
|
+
* ```
|
|
614
540
|
*/
|
|
615
541
|
function parseProvenance(markdown) {
|
|
616
542
|
const spans = /* @__PURE__ */ new Map();
|
|
@@ -630,714 +556,406 @@ function parseProvenance(markdown) {
|
|
|
630
556
|
*
|
|
631
557
|
* @param text - The inline markdown text to parse.
|
|
632
558
|
* @returns The parsed inline nodes.
|
|
559
|
+
*
|
|
560
|
+
* @example
|
|
561
|
+
* ```ts
|
|
562
|
+
* parseInline('a *b*') // [{ element: 'text', value: 'a ' }, { element: 'emphasis', ... }]
|
|
563
|
+
* ```
|
|
633
564
|
*/
|
|
634
565
|
function parseInline(text) {
|
|
635
566
|
return coalesceText(scanInline(text, 0, text.length));
|
|
636
567
|
}
|
|
637
568
|
//#endregion
|
|
638
|
-
//#region src/core/
|
|
569
|
+
//#region src/core/helpers.ts
|
|
639
570
|
/**
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
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.
|
|
643
574
|
*
|
|
644
|
-
* @
|
|
645
|
-
*
|
|
646
|
-
* block phase then the inline phase) once, keeping the AST and a COPY of the span map
|
|
647
|
-
* that parse recorded. Given a {@link MarkdownDocument}, the document is adopted AS-IS
|
|
648
|
-
* and is NOT re-validated - gate an untrusted value with `isMarkdownDocument` first.
|
|
649
|
-
* - **Provenance.** {@link span} reads the region of the ORIGINAL constructor string a
|
|
650
|
-
* node was produced from, and it is handle-relative: a string-constructed handle exposes
|
|
651
|
-
* the regions of the nodes it parsed, an adopted document exposes none, and a node from
|
|
652
|
-
* another handle reports `undefined` here whatever that handle reports. Each call
|
|
653
|
-
* returns a fresh value. A node reports the region THIS handle holds for its identity,
|
|
654
|
-
* else the region of the direct input a rewrite named for it, else `undefined`: a text
|
|
655
|
-
* run the parse joined from adjacent scanner output reports the region enclosing its
|
|
656
|
-
* parts, and only a rewrite output that holds no region of its own and was assembled
|
|
657
|
-
* from separate source nodes reports `undefined`.
|
|
658
|
-
* {@link map} carries provenance across the rewrite: an unchanged node keeps its
|
|
659
|
-
* region, a one-source replacement takes the region of the node it replaced, and a
|
|
660
|
-
* rebuilt parent takes its original's.
|
|
661
|
-
* - **Immutable.** {@link map} never mutates the stored AST - it returns a NEW `Markdown`
|
|
662
|
-
* instance; the document root invariant (`element: 'document'`) always holds. An
|
|
663
|
-
* identity rewrite still returns a new handle, over the same document tree.
|
|
664
|
-
* - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
|
|
665
|
-
* on it walk the AST depth-first, pre-order, root-inclusive (via {@link walkNodes});
|
|
666
|
-
* `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
|
|
667
577
|
*
|
|
668
578
|
* @example
|
|
669
579
|
* ```ts
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
* const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
|
|
673
|
-
* const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
|
|
674
|
-
* const shouted = markdown.map((node) =>
|
|
675
|
-
* node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
|
|
676
|
-
* )
|
|
677
|
-
* 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 }] }, ...]
|
|
678
581
|
* ```
|
|
679
582
|
*/
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
this.#document = input;
|
|
690
|
-
this.#spans = /* @__PURE__ */ new Map();
|
|
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;
|
|
691
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;
|
|
692
603
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
*
|
|
705
|
-
* @example
|
|
706
|
-
* ```ts
|
|
707
|
-
* const source = '# Title\n\npara'
|
|
708
|
-
* const markdown = new Markdown(source)
|
|
709
|
-
* const heading = markdown.find(isHeadingNode)
|
|
710
|
-
* const span = heading && markdown.span(heading)
|
|
711
|
-
* span && source.slice(span.start, span.end) // '# Title'
|
|
712
|
-
* ```
|
|
713
|
-
*/
|
|
714
|
-
span(node) {
|
|
715
|
-
const span = this.#spans.get(node);
|
|
716
|
-
return span === void 0 ? void 0 : {
|
|
717
|
-
start: span.start,
|
|
718
|
-
end: span.end
|
|
719
|
-
};
|
|
720
|
-
}
|
|
721
|
-
/**
|
|
722
|
-
* THE deep traversal - a lazy, depth-first, pre-order, root-inclusive generator
|
|
723
|
-
* over every {@link MarkdownNode} in the document. `find` / `filter` / `reduce`
|
|
724
|
-
* all iterate this single traversal.
|
|
725
|
-
*
|
|
726
|
-
* @example
|
|
727
|
-
* ```ts
|
|
728
|
-
* for (const node of markdown.walk()) {
|
|
729
|
-
* // every node, depth-first, pre-order, root-inclusive
|
|
730
|
-
* }
|
|
731
|
-
*
|
|
732
|
-
* // also consumable by for-await - JS accepts a sync iterable in for-await
|
|
733
|
-
* for await (const node of markdown.walk()) {
|
|
734
|
-
* // same sequence, no separate async iterator needed
|
|
735
|
-
* }
|
|
736
|
-
* ```
|
|
737
|
-
*/
|
|
738
|
-
*walk() {
|
|
739
|
-
yield* walkNodes(this.#document);
|
|
740
|
-
}
|
|
741
|
-
find(predicate) {
|
|
742
|
-
for (const node of this.walk()) if (predicate(node)) return node;
|
|
743
|
-
}
|
|
744
|
-
filter(predicate) {
|
|
745
|
-
const out = [];
|
|
746
|
-
for (const node of this.walk()) if (predicate(node)) out.push(node);
|
|
747
|
-
return out;
|
|
748
|
-
}
|
|
749
|
-
/**
|
|
750
|
-
* Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown},
|
|
751
|
-
* carrying each output node's provenance across the rewrite. A rewrite that returns
|
|
752
|
-
* its node unchanged shares that subtree instead of copying it, so an identity
|
|
753
|
-
* rewrite copies no node and still returns a new handle.
|
|
754
|
-
*
|
|
755
|
-
* @param rewrite - The bottom-up node rewrite
|
|
756
|
-
* @returns A new handle over the rewritten document
|
|
757
|
-
*/
|
|
758
|
-
map(rewrite) {
|
|
759
|
-
const [document, derivations] = rewriteDocument(this.#document, rewrite);
|
|
760
|
-
return this.#derive(document, derivations);
|
|
761
|
-
}
|
|
762
|
-
/** Folds the AST depth-first, pre-order into an accumulator. */
|
|
763
|
-
reduce(callback, initial) {
|
|
764
|
-
let accumulator = initial;
|
|
765
|
-
for (const node of this.walk()) accumulator = callback(accumulator, node);
|
|
766
|
-
return accumulator;
|
|
767
|
-
}
|
|
768
|
-
/** Runs a total catamorphism over the document using a {@link MarkdownHandlers} table. */
|
|
769
|
-
fold(handlers) {
|
|
770
|
-
return foldNode(this.#document, handlers, 0);
|
|
771
|
-
}
|
|
772
|
-
/**
|
|
773
|
-
* A web-standard {@link ReadableStream} over the document's top-level block nodes
|
|
774
|
-
* (shallow, source order) - a fresh, pull-based source per call: one block is
|
|
775
|
-
* enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
|
|
776
|
-
* async-iterable wherever the platform supports it (Node, Deno), and pipeable
|
|
777
|
-
* through any {@link TransformStream} / {@link WritableStream}.
|
|
778
|
-
*
|
|
779
|
-
* @example
|
|
780
|
-
* ```ts
|
|
781
|
-
* // universal - works in every ReadableStream-supporting environment
|
|
782
|
-
* const reader = markdown.stream().getReader()
|
|
783
|
-
* for (let result = await reader.read(); !result.done; result = await reader.read()) {
|
|
784
|
-
* console.log(result.value) // one BlockNode
|
|
785
|
-
* }
|
|
786
|
-
*
|
|
787
|
-
* // Node / Deno / Firefox support async iteration of ReadableStream natively;
|
|
788
|
-
* // other environments should use the reader loop above instead.
|
|
789
|
-
* for await (const block of markdown.stream()) {
|
|
790
|
-
* console.log(block)
|
|
791
|
-
* }
|
|
792
|
-
* ```
|
|
793
|
-
*/
|
|
794
|
-
stream() {
|
|
795
|
-
const blocks = this.#document.children;
|
|
796
|
-
let index = 0;
|
|
797
|
-
return new ReadableStream({ pull(controller) {
|
|
798
|
-
if (index < blocks.length) {
|
|
799
|
-
const block = blocks[index];
|
|
800
|
-
if (block === void 0) {
|
|
801
|
-
controller.close();
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
|
-
controller.enqueue(block);
|
|
805
|
-
index += 1;
|
|
806
|
-
} else controller.close();
|
|
807
|
-
} });
|
|
808
|
-
}
|
|
809
|
-
#derive(document, derivations) {
|
|
810
|
-
const derived = new Markdown(document);
|
|
811
|
-
for (const node of walkNodes(document)) {
|
|
812
|
-
const own = this.#spans.get(node);
|
|
813
|
-
if (own !== void 0) {
|
|
814
|
-
derived.#spans.set(node, own);
|
|
815
|
-
continue;
|
|
816
|
-
}
|
|
817
|
-
const source = derivations.get(node);
|
|
818
|
-
if (source === void 0) continue;
|
|
819
|
-
const span = this.#spans.get(source);
|
|
820
|
-
if (span !== void 0) derived.#spans.set(node, span);
|
|
821
|
-
}
|
|
822
|
-
return derived;
|
|
823
|
-
}
|
|
824
|
-
};
|
|
825
|
-
//#endregion
|
|
826
|
-
//#region src/core/shapers.ts
|
|
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
|
+
}
|
|
827
615
|
/**
|
|
828
|
-
*
|
|
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
|
|
829
623
|
*
|
|
830
624
|
* @example
|
|
831
625
|
* ```ts
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
* const text = createContract(textShape)
|
|
836
|
-
* text.is({ element: 'text', value: 'hi' }) // true
|
|
626
|
+
* sliceSource({ text: 'abc', segments: [{ offset: 0, start: 4, end: 7 }] }, 1, 3)
|
|
627
|
+
* // { text: 'bc', segments: [{ offset: 0, start: 5, end: 7 }] }
|
|
837
628
|
* ```
|
|
838
629
|
*/
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
text: source.text.slice(start, end),
|
|
653
|
+
segments
|
|
654
|
+
};
|
|
655
|
+
}
|
|
843
656
|
/**
|
|
844
|
-
*
|
|
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
|
|
845
663
|
*
|
|
846
664
|
* @example
|
|
847
665
|
* ```ts
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
* const codeSpan = createContract(codeSpanShape)
|
|
852
|
-
* codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
|
|
666
|
+
* joinSources(splitLines('a\nb'), '\n')
|
|
667
|
+
* // { text: 'a\nb', segments: [...] }
|
|
853
668
|
* ```
|
|
854
669
|
*/
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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;
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
text,
|
|
696
|
+
segments
|
|
697
|
+
};
|
|
698
|
+
}
|
|
859
699
|
/**
|
|
860
|
-
*
|
|
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
|
|
861
707
|
*
|
|
862
708
|
* @example
|
|
863
709
|
* ```ts
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
* const lineBreak = createContract(lineBreakShape)
|
|
868
|
-
* lineBreak.is({ element: 'break' }) // true
|
|
710
|
+
* projectSpan({ text: 'a', segments: [{ offset: 0, start: 4, end: 5 }] }, 0, 1)
|
|
711
|
+
* // { start: 4, end: 5 }
|
|
869
712
|
* ```
|
|
870
713
|
*/
|
|
871
|
-
|
|
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
|
+
}
|
|
872
739
|
/**
|
|
873
|
-
*
|
|
874
|
-
*
|
|
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
|
|
875
744
|
*
|
|
876
745
|
* @example
|
|
877
746
|
* ```ts
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
* const codeBlock = createContract(codeBlockShape)
|
|
882
|
-
* codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
|
|
883
|
-
* codeBlock.is({ element: 'codeBlock', code: 'x', lang: 'ts' }) // true
|
|
747
|
+
* trimSource({ text: ' a ', segments: [{ offset: 0, start: 4, end: 7 }] })
|
|
748
|
+
* // { text: 'a', segments: [{ offset: 0, start: 5, end: 6 }] }
|
|
884
749
|
* ```
|
|
885
750
|
*/
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
}
|
|
891
|
-
/**
|
|
892
|
-
*
|
|
893
|
-
*
|
|
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
|
+
}
|
|
756
|
+
/**
|
|
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
|
|
894
764
|
*
|
|
895
765
|
* @example
|
|
896
766
|
* ```ts
|
|
897
|
-
*
|
|
898
|
-
* import { thematicBreakShape } from '@src/core'
|
|
899
|
-
*
|
|
900
|
-
* const thematicBreak = createContract(thematicBreakShape)
|
|
901
|
-
* thematicBreak.is({ element: 'thematicBreak' }) // true
|
|
767
|
+
* normalizeParagraphLine(splitLines('text \nnext')[0], true).text // 'text '
|
|
902
768
|
* ```
|
|
903
769
|
*/
|
|
904
|
-
|
|
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
|
+
}
|
|
905
784
|
/**
|
|
906
|
-
*
|
|
907
|
-
*
|
|
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
|
|
908
790
|
*
|
|
909
791
|
* @example
|
|
910
792
|
* ```ts
|
|
911
|
-
*
|
|
912
|
-
* import { tableAlignShape } from '@src/core'
|
|
913
|
-
*
|
|
914
|
-
* const tableAlign = createContract(tableAlignShape)
|
|
915
|
-
* tableAlign.is('left') // true
|
|
916
|
-
* tableAlign.is('center') // true
|
|
917
|
-
* tableAlign.is('top') // false
|
|
793
|
+
* countIndent(' text') // 2
|
|
918
794
|
* ```
|
|
919
795
|
*/
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
"
|
|
923
|
-
|
|
924
|
-
|
|
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
|
+
}
|
|
925
802
|
/**
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
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
|
|
929
808
|
*
|
|
930
809
|
* @example
|
|
931
810
|
* ```ts
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
* const listItemParts = createContract(listItemMatchShape)
|
|
936
|
-
* listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
|
|
811
|
+
* isFlankingWhitespace(' ') // true
|
|
812
|
+
* isFlankingWhitespace('a') // false
|
|
937
813
|
* ```
|
|
938
814
|
*/
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
content: (0, _orkestrel_contract.stringShape)(),
|
|
943
|
-
indent: (0, _orkestrel_contract.integerShape)(),
|
|
944
|
-
marker: (0, _orkestrel_contract.integerShape)()
|
|
945
|
-
});
|
|
946
|
-
//#endregion
|
|
947
|
-
//#region src/core/factories.ts
|
|
815
|
+
function isFlankingWhitespace(character) {
|
|
816
|
+
return character === " " || character === " " || character === "\n";
|
|
817
|
+
}
|
|
948
818
|
/**
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
* @remarks
|
|
953
|
-
* A block-bearing projection cannot also expose inline content. Callers may provide
|
|
954
|
-
* both views, but `inlines` is flushed whenever `blocks` is non-empty.
|
|
819
|
+
* Checks whether `character` is escapable by a leading backslash - the ASCII punctuation
|
|
820
|
+
* markdown gives meaning to (so `\*` becomes `*` but `\.` stays `\.`).
|
|
955
821
|
*
|
|
956
|
-
* @param
|
|
957
|
-
* @returns
|
|
822
|
+
* @param character - The single character after a backslash
|
|
823
|
+
* @returns True if a backslash before it is an escape; false otherwise
|
|
958
824
|
*
|
|
959
825
|
* @example
|
|
960
826
|
* ```ts
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
* inlines: [{ element: 'text', value: 'discarded' }],
|
|
964
|
-
* })
|
|
965
|
-
* // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
|
|
827
|
+
* isEscapable('*') // true
|
|
828
|
+
* isEscapable('a') // false
|
|
966
829
|
* ```
|
|
967
830
|
*/
|
|
968
|
-
function
|
|
969
|
-
|
|
970
|
-
return {
|
|
971
|
-
blocks,
|
|
972
|
-
inlines: blocks.length === 0 ? parts.inlines ?? EMPTY_PROJECTION.inlines : [],
|
|
973
|
-
text: parts.text ?? EMPTY_PROJECTION.text,
|
|
974
|
-
cells: parts.cells ?? EMPTY_PROJECTION.cells,
|
|
975
|
-
rows: parts.rows ?? EMPTY_PROJECTION.rows
|
|
976
|
-
};
|
|
831
|
+
function isEscapable(character) {
|
|
832
|
+
return /[\\`*_{}[\]()#+\-.!>~|]/.test(character);
|
|
977
833
|
}
|
|
978
834
|
/**
|
|
979
|
-
*
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
* @remarks
|
|
984
|
-
* Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
|
|
985
|
-
* fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
|
|
986
|
-
* inline code / links / images / hard breaks) to build a render-agnostic
|
|
987
|
-
* {@link MarkdownDocument}. Given a
|
|
988
|
-
* {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
|
|
989
|
-
* value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
|
|
990
|
-
* degrades to text, never throws) and zero-dependency - a hand-written scanner, no
|
|
991
|
-
* regex-only structural parse, linear-time (no ReDoS).
|
|
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.
|
|
992
838
|
*
|
|
993
|
-
* @param
|
|
994
|
-
* @returns
|
|
839
|
+
* @param line - The candidate line
|
|
840
|
+
* @returns True if the line is blank; false otherwise
|
|
995
841
|
*
|
|
996
842
|
* @example
|
|
997
843
|
* ```ts
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
* const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
|
|
1001
|
-
* markdown.document.children[0] // { element: 'heading', ... }
|
|
844
|
+
* isBlankLine(' ') // true
|
|
1002
845
|
* ```
|
|
1003
846
|
*/
|
|
1004
|
-
function
|
|
1005
|
-
return
|
|
847
|
+
function isBlankLine(line) {
|
|
848
|
+
return (0, _orkestrel_contract.isEmptyString)(line.trim());
|
|
1006
849
|
}
|
|
1007
850
|
/**
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
* generator from one shape declaration (AGENTS §14).
|
|
851
|
+
* Checks whether `line` is a blockquote line (`>` optionally indented up to three spaces) -
|
|
852
|
+
* its content is de-quoted by {@link stripQuote}.
|
|
1011
853
|
*
|
|
1012
|
-
* @
|
|
854
|
+
* @param line - The candidate line
|
|
855
|
+
* @returns True if the line begins a blockquote; false otherwise
|
|
1013
856
|
*
|
|
1014
857
|
* @example
|
|
1015
858
|
* ```ts
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
* const text = createTextContract()
|
|
1019
|
-
* text.is({ element: 'text', value: 'hi' }) // true
|
|
859
|
+
* isQuote('> quoted') // true
|
|
1020
860
|
* ```
|
|
1021
861
|
*/
|
|
1022
|
-
function
|
|
1023
|
-
return
|
|
862
|
+
function isQuote(line) {
|
|
863
|
+
return /^\s{0,3}>/.test(line);
|
|
1024
864
|
}
|
|
1025
865
|
/**
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
* generator from one shape declaration (AGENTS §14).
|
|
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.
|
|
1029
868
|
*
|
|
1030
|
-
* @
|
|
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
|
|
1031
872
|
*
|
|
1032
873
|
* @example
|
|
1033
874
|
* ```ts
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
* const codeSpan = createCodeSpanContract()
|
|
1037
|
-
* codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
|
|
875
|
+
* isFenceClose('```', '```') // true
|
|
1038
876
|
* ```
|
|
1039
877
|
*/
|
|
1040
|
-
function
|
|
1041
|
-
|
|
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;
|
|
1042
890
|
}
|
|
1043
891
|
/**
|
|
1044
|
-
*
|
|
1045
|
-
* {@link
|
|
892
|
+
* Checks whether `character` is a regex-`\s`-equivalent whitespace character - the
|
|
893
|
+
* character class {@link isFenceClose}'s scan treats as surrounding padding.
|
|
1046
894
|
*
|
|
1047
|
-
* @
|
|
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
|
|
1048
897
|
*
|
|
1049
898
|
* @example
|
|
1050
899
|
* ```ts
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
* createLineBreakContract().is({ element: 'break' }) // true
|
|
900
|
+
* isFenceWhitespace(' ') // true
|
|
901
|
+
* isFenceWhitespace(undefined) // false
|
|
1054
902
|
* ```
|
|
1055
903
|
*/
|
|
1056
|
-
function
|
|
1057
|
-
return
|
|
904
|
+
function isFenceWhitespace(character) {
|
|
905
|
+
return character === " " || character === " " || character === "\n" || character === "\r" || character === "\f" || character === "\v";
|
|
1058
906
|
}
|
|
1059
907
|
/**
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
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
|
+
* `***`, `___`, `- - -`).
|
|
1063
911
|
*
|
|
1064
|
-
* @
|
|
912
|
+
* @param line - The candidate line
|
|
913
|
+
* @returns True if the line is a thematic break; false otherwise
|
|
1065
914
|
*
|
|
1066
915
|
* @example
|
|
1067
916
|
* ```ts
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
* const codeBlock = createCodeBlockContract()
|
|
1071
|
-
* codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
|
|
917
|
+
* isThematicBreak('---') // true
|
|
1072
918
|
* ```
|
|
1073
919
|
*/
|
|
1074
|
-
function
|
|
1075
|
-
|
|
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);
|
|
1076
926
|
}
|
|
1077
927
|
/**
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
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.
|
|
1081
931
|
*
|
|
1082
|
-
* @
|
|
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
|
|
1083
935
|
*
|
|
1084
936
|
* @example
|
|
1085
937
|
* ```ts
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
1088
|
-
* const thematicBreak = createThematicBreakContract()
|
|
1089
|
-
* thematicBreak.is({ element: 'thematicBreak' }) // true
|
|
938
|
+
* isTableStart('| a |', '| - |') // true
|
|
1090
939
|
* ```
|
|
1091
940
|
*/
|
|
1092
|
-
function
|
|
1093
|
-
|
|
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()));
|
|
1094
946
|
}
|
|
1095
|
-
//#endregion
|
|
1096
|
-
//#region src/core/helpers.ts
|
|
1097
947
|
/**
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
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.
|
|
1101
952
|
*
|
|
1102
|
-
* @param
|
|
1103
|
-
* @returns The
|
|
953
|
+
* @param line - The candidate line
|
|
954
|
+
* @returns The heading level (1–6), raw inline text, and text offset, or `undefined`
|
|
1104
955
|
*
|
|
1105
956
|
* @example
|
|
1106
957
|
* ```ts
|
|
1107
|
-
*
|
|
1108
|
-
* ```
|
|
1109
|
-
*/
|
|
1110
|
-
function splitLines(markdown) {
|
|
1111
|
-
const lines = [];
|
|
1112
|
-
let start = 0;
|
|
1113
|
-
let index = 0;
|
|
1114
|
-
while (index < markdown.length) {
|
|
1115
|
-
const character = markdown[index];
|
|
1116
|
-
if (character !== "\r" && character !== "\n") {
|
|
1117
|
-
index += 1;
|
|
1118
|
-
continue;
|
|
1119
|
-
}
|
|
1120
|
-
lines.push({
|
|
1121
|
-
text: markdown.slice(start, index),
|
|
1122
|
-
segments: [{
|
|
1123
|
-
offset: 0,
|
|
1124
|
-
start,
|
|
1125
|
-
end: index
|
|
1126
|
-
}]
|
|
1127
|
-
});
|
|
1128
|
-
index += character === "\r" && markdown[index + 1] === "\n" ? 2 : 1;
|
|
1129
|
-
start = index;
|
|
1130
|
-
}
|
|
1131
|
-
lines.push({
|
|
1132
|
-
text: markdown.slice(start),
|
|
1133
|
-
segments: [{
|
|
1134
|
-
offset: 0,
|
|
1135
|
-
start,
|
|
1136
|
-
end: markdown.length
|
|
1137
|
-
}]
|
|
1138
|
-
});
|
|
1139
|
-
if (lines.length > 1 && lines[lines.length - 1]?.text === "") lines.pop();
|
|
1140
|
-
return lines;
|
|
1141
|
-
}
|
|
1142
|
-
/**
|
|
1143
|
-
* Slices derived markdown text and narrows each intersecting source segment to the
|
|
1144
|
-
* same text-relative range.
|
|
1145
|
-
*
|
|
1146
|
-
* @param source - The offset-bearing source to slice
|
|
1147
|
-
* @param from - The inclusive text offset
|
|
1148
|
-
* @param to - The exclusive text offset
|
|
1149
|
-
* @returns The sliced text and its narrowed original-string segments
|
|
1150
|
-
*
|
|
1151
|
-
* @example
|
|
1152
|
-
* ```ts
|
|
1153
|
-
* sliceSource({ text: 'abc', segments: [{ offset: 0, start: 4, end: 7 }] }, 1, 3)
|
|
1154
|
-
* // { text: 'bc', segments: [{ offset: 0, start: 5, end: 7 }] }
|
|
1155
|
-
* ```
|
|
1156
|
-
*/
|
|
1157
|
-
function sliceSource(source, from, to) {
|
|
1158
|
-
const start = Math.max(0, Math.min(from, source.text.length));
|
|
1159
|
-
const end = Math.max(start, Math.min(to, source.text.length));
|
|
1160
|
-
const segments = [];
|
|
1161
|
-
for (let index = 0; index < source.segments.length; index += 1) {
|
|
1162
|
-
const segment = source.segments[index];
|
|
1163
|
-
if (segment === void 0) continue;
|
|
1164
|
-
const next = source.segments[index + 1];
|
|
1165
|
-
const limit = Math.min(segment.offset + (segment.end - segment.start), next === void 0 ? source.text.length : next.offset);
|
|
1166
|
-
const overlapStart = Math.max(start, segment.offset);
|
|
1167
|
-
const overlapEnd = Math.min(end, limit);
|
|
1168
|
-
const empty = segment.offset === limit && overlapStart === segment.offset;
|
|
1169
|
-
if (overlapStart >= overlapEnd && !empty) continue;
|
|
1170
|
-
const originalStart = overlapStart === limit ? segment.end : Math.min(segment.end, segment.start + overlapStart - segment.offset);
|
|
1171
|
-
const originalEnd = overlapEnd === limit ? segment.end : Math.min(segment.end, segment.start + overlapEnd - segment.offset);
|
|
1172
|
-
segments.push({
|
|
1173
|
-
offset: overlapStart - start,
|
|
1174
|
-
start: originalStart,
|
|
1175
|
-
end: originalEnd
|
|
1176
|
-
});
|
|
1177
|
-
}
|
|
1178
|
-
return {
|
|
1179
|
-
text: source.text.slice(start, end),
|
|
1180
|
-
segments
|
|
1181
|
-
};
|
|
1182
|
-
}
|
|
1183
|
-
/**
|
|
1184
|
-
* Joins offset-bearing markdown sources while mapping a separator to the original
|
|
1185
|
-
* region between adjacent mapped sources.
|
|
1186
|
-
*
|
|
1187
|
-
* @param sources - The sources to join
|
|
1188
|
-
* @param separator - The derived text inserted between sources
|
|
1189
|
-
* @returns The joined text and every source-backed segment
|
|
1190
|
-
*
|
|
1191
|
-
* @example
|
|
1192
|
-
* ```ts
|
|
1193
|
-
* joinSources(splitLines('a\nb'), '\n')
|
|
1194
|
-
* // { text: 'a\nb', segments: [...] }
|
|
1195
|
-
* ```
|
|
1196
|
-
*/
|
|
1197
|
-
function joinSources(sources, separator) {
|
|
1198
|
-
let text = "";
|
|
1199
|
-
const segments = [];
|
|
1200
|
-
for (let index = 0; index < sources.length; index += 1) {
|
|
1201
|
-
const source = sources[index];
|
|
1202
|
-
if (source === void 0) continue;
|
|
1203
|
-
if (index > 0) {
|
|
1204
|
-
const previous = sources[index - 1];
|
|
1205
|
-
const left = previous?.segments[previous.segments.length - 1];
|
|
1206
|
-
const right = source.segments[0];
|
|
1207
|
-
if (separator.length > 0 && left !== void 0 && right !== void 0 && left.end < right.start) segments.push({
|
|
1208
|
-
offset: text.length,
|
|
1209
|
-
start: left.end,
|
|
1210
|
-
end: right.start
|
|
1211
|
-
});
|
|
1212
|
-
text += separator;
|
|
1213
|
-
}
|
|
1214
|
-
for (const segment of source.segments) segments.push({
|
|
1215
|
-
offset: text.length + segment.offset,
|
|
1216
|
-
start: segment.start,
|
|
1217
|
-
end: segment.end
|
|
1218
|
-
});
|
|
1219
|
-
text += source.text;
|
|
1220
|
-
}
|
|
1221
|
-
return {
|
|
1222
|
-
text,
|
|
1223
|
-
segments
|
|
1224
|
-
};
|
|
1225
|
-
}
|
|
1226
|
-
/**
|
|
1227
|
-
* Projects a derived text range through its segments to a half-open region of the
|
|
1228
|
-
* original markdown string.
|
|
1229
|
-
*
|
|
1230
|
-
* @param source - The offset-bearing source carrying the range
|
|
1231
|
-
* @param from - The inclusive derived-text boundary
|
|
1232
|
-
* @param to - The exclusive derived-text boundary
|
|
1233
|
-
* @returns The original-string span, or `undefined` when either boundary is unmapped
|
|
1234
|
-
*
|
|
1235
|
-
* @example
|
|
1236
|
-
* ```ts
|
|
1237
|
-
* projectSpan({ text: 'a', segments: [{ offset: 0, start: 4, end: 5 }] }, 0, 1)
|
|
1238
|
-
* // { start: 4, end: 5 }
|
|
1239
|
-
* ```
|
|
1240
|
-
*/
|
|
1241
|
-
function projectSpan(source, from, to) {
|
|
1242
|
-
if (from < 0 || to < from || to > source.text.length) return void 0;
|
|
1243
|
-
let start;
|
|
1244
|
-
let end;
|
|
1245
|
-
for (let index = 0; index < source.segments.length; index += 1) {
|
|
1246
|
-
const segment = source.segments[index];
|
|
1247
|
-
if (segment === void 0) continue;
|
|
1248
|
-
const next = source.segments[index + 1];
|
|
1249
|
-
const limit = Math.min(segment.offset + (segment.end - segment.start), next === void 0 ? source.text.length : next.offset);
|
|
1250
|
-
if (from === to && from >= segment.offset && from <= limit) {
|
|
1251
|
-
if (next !== void 0 && from === next.offset) continue;
|
|
1252
|
-
const position = from === limit ? segment.end : Math.min(segment.end, segment.start + from - segment.offset);
|
|
1253
|
-
return {
|
|
1254
|
-
start: position,
|
|
1255
|
-
end: position
|
|
1256
|
-
};
|
|
1257
|
-
}
|
|
1258
|
-
if (start === void 0 && from >= segment.offset && from < limit) start = segment.start + from - segment.offset;
|
|
1259
|
-
if (to > segment.offset && to <= limit) end = to === limit ? segment.end : Math.min(segment.end, segment.start + to - segment.offset);
|
|
1260
|
-
}
|
|
1261
|
-
return start === void 0 || end === void 0 ? void 0 : {
|
|
1262
|
-
start,
|
|
1263
|
-
end
|
|
1264
|
-
};
|
|
1265
|
-
}
|
|
1266
|
-
/**
|
|
1267
|
-
* Trims an offset-bearing source without losing the coordinates of its retained text.
|
|
1268
|
-
*
|
|
1269
|
-
* @param source - The source to trim
|
|
1270
|
-
* @returns The trimmed text and its narrowed original-string segments
|
|
1271
|
-
*
|
|
1272
|
-
* @example
|
|
1273
|
-
* ```ts
|
|
1274
|
-
* trimSource({ text: ' a ', segments: [{ offset: 0, start: 4, end: 7 }] })
|
|
1275
|
-
* // { text: 'a', segments: [{ offset: 0, start: 5, end: 6 }] }
|
|
1276
|
-
* ```
|
|
1277
|
-
*/
|
|
1278
|
-
function trimSource(source) {
|
|
1279
|
-
const start = source.text.length - source.text.trimStart().length;
|
|
1280
|
-
const end = source.text.trimEnd().length;
|
|
1281
|
-
return sliceSource(source, start, Math.max(start, end));
|
|
1282
|
-
}
|
|
1283
|
-
/**
|
|
1284
|
-
* Normalizes one paragraph line while retaining the full source run consumed by a
|
|
1285
|
-
* trailing-space hard break.
|
|
1286
|
-
*
|
|
1287
|
-
* @param source - The offset-bearing paragraph line
|
|
1288
|
-
* @param breaks - If `true`, preserves a trailing run of at least two spaces as the
|
|
1289
|
-
* scanner's two-space hard-break syntax; if `false`, trims the line normally
|
|
1290
|
-
* @returns The normalized line and its original-string segments
|
|
1291
|
-
*
|
|
1292
|
-
* @example
|
|
1293
|
-
* ```ts
|
|
1294
|
-
* normalizeParagraphLine(splitLines('text \nnext')[0], true).text // 'text '
|
|
1295
|
-
* ```
|
|
1296
|
-
*/
|
|
1297
|
-
function normalizeParagraphLine(source, breaks) {
|
|
1298
|
-
if (!breaks || !source.text.endsWith(" ")) return trimSource(source);
|
|
1299
|
-
const contentEnd = source.text.trimEnd().length;
|
|
1300
|
-
const content = trimSource(sliceSource(source, 0, contentEnd));
|
|
1301
|
-
const span = projectSpan(source, contentEnd, source.text.length);
|
|
1302
|
-
return joinSources([content, {
|
|
1303
|
-
text: " ",
|
|
1304
|
-
segments: span === void 0 ? [] : [{
|
|
1305
|
-
offset: 0,
|
|
1306
|
-
start: span.start,
|
|
1307
|
-
end: span.end
|
|
1308
|
-
}]
|
|
1309
|
-
}], "");
|
|
1310
|
-
}
|
|
1311
|
-
/**
|
|
1312
|
-
* The count of leading space / tab characters on `line` (a tab counts as one) - the
|
|
1313
|
-
* indent that decides whether a list item's continuation belongs to the item.
|
|
1314
|
-
*
|
|
1315
|
-
* @param line - The line to measure
|
|
1316
|
-
* @returns The number of leading space / tab characters
|
|
1317
|
-
*
|
|
1318
|
-
* @example
|
|
1319
|
-
* ```ts
|
|
1320
|
-
* countIndent(' text') // 2
|
|
1321
|
-
* ```
|
|
1322
|
-
*/
|
|
1323
|
-
function countIndent(line) {
|
|
1324
|
-
let count = 0;
|
|
1325
|
-
for (const character of line) if (character === " " || character === " ") count += 1;
|
|
1326
|
-
else break;
|
|
1327
|
-
return count;
|
|
1328
|
-
}
|
|
1329
|
-
/**
|
|
1330
|
-
* Extracts an ATX heading line (`#` … `######` followed by text) into its level,
|
|
1331
|
-
* trimmed text, and the text's offset inside the line. A run of more than 6 `#`s, or
|
|
1332
|
-
* `#`s not followed by whitespace + text, is not a heading; an optional closing
|
|
1333
|
-
* `###` run is stripped.
|
|
1334
|
-
*
|
|
1335
|
-
* @param line - The candidate line
|
|
1336
|
-
* @returns The heading level (1–6), raw inline text, and text offset, or `undefined`
|
|
1337
|
-
*
|
|
1338
|
-
* @example
|
|
1339
|
-
* ```ts
|
|
1340
|
-
* extractHeading('## Title') // { level: 2, text: 'Title', offset: 3 }
|
|
958
|
+
* extractHeading('## Title') // { level: 2, text: 'Title', offset: 3 }
|
|
1341
959
|
* ```
|
|
1342
960
|
*/
|
|
1343
961
|
function extractHeading(line) {
|
|
@@ -1357,7 +975,7 @@ function extractHeading(line) {
|
|
|
1357
975
|
};
|
|
1358
976
|
}
|
|
1359
977
|
/**
|
|
1360
|
-
*
|
|
978
|
+
* Extracts a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
|
|
1361
979
|
* string) into its `{ marker, lang }`, or `undefined` when `line` is not a fence
|
|
1362
980
|
* opener. `marker` is the exact fence run (the closer must match the same character +
|
|
1363
981
|
* at least the same length); `lang` is the first word of the info string.
|
|
@@ -1382,7 +1000,7 @@ function extractFence(line) {
|
|
|
1382
1000
|
};
|
|
1383
1001
|
}
|
|
1384
1002
|
/**
|
|
1385
|
-
*
|
|
1003
|
+
* Extracts a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
|
|
1386
1004
|
* a space) into its {@link ListItemMatch}, or `undefined` when `line` is not a list
|
|
1387
1005
|
* item. `content` is the text after the marker; `marker` is the full marker-plus-space
|
|
1388
1006
|
* width (for measuring a continuation's indent).
|
|
@@ -1439,7 +1057,7 @@ function stripQuote(source) {
|
|
|
1439
1057
|
return sliceSource(source, (/^\s{0,3}>\s?/.exec(source.text)?.[0] ?? "").length, source.text.length);
|
|
1440
1058
|
}
|
|
1441
1059
|
/**
|
|
1442
|
-
*
|
|
1060
|
+
* Splits one GFM table row into its cell strings - outer pipes are optional, an escaped
|
|
1443
1061
|
* pipe (`\|`) inside a cell is NOT a separator (it becomes a literal `|`), and the
|
|
1444
1062
|
* empty leading / trailing cell produced by an outer `|` is dropped. Derives the string
|
|
1445
1063
|
* form from {@link splitTableSources}, which owns the escaped-pipe splitting rule.
|
|
@@ -1505,7 +1123,7 @@ function splitTableSources(row) {
|
|
|
1505
1123
|
return cells;
|
|
1506
1124
|
}
|
|
1507
1125
|
/**
|
|
1508
|
-
*
|
|
1126
|
+
* Derives the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
|
|
1509
1127
|
* left, `---:` right, `:---:` center, and `---` as the explicit no-alignment
|
|
1510
1128
|
* marker represented by `null`.
|
|
1511
1129
|
*
|
|
@@ -1529,7 +1147,7 @@ function delimiterToAlignments(delimiter) {
|
|
|
1529
1147
|
});
|
|
1530
1148
|
}
|
|
1531
1149
|
/**
|
|
1532
|
-
*
|
|
1150
|
+
* Checks whether the line at `index` starts a NEW block kind (heading / fence / thematic
|
|
1533
1151
|
* break / blockquote / list / table) - the paragraph collector stops at such a line
|
|
1534
1152
|
* so a block following a paragraph without a blank line still parses (a trusted-input
|
|
1535
1153
|
* caller writing a `##` heading directly under a paragraph, with no intervening blank
|
|
@@ -1537,7 +1155,7 @@ function delimiterToAlignments(delimiter) {
|
|
|
1537
1155
|
*
|
|
1538
1156
|
* @param lines - The document's lines
|
|
1539
1157
|
* @param index - The line index to test
|
|
1540
|
-
* @returns
|
|
1158
|
+
* @returns True if the line begins a different block; false otherwise
|
|
1541
1159
|
*
|
|
1542
1160
|
* @example
|
|
1543
1161
|
* ```ts
|
|
@@ -1549,7 +1167,7 @@ function startsBlock(lines, index) {
|
|
|
1549
1167
|
return extractHeading(line) !== void 0 || extractFence(line) !== void 0 || isThematicBreak(line) || isQuote(line) || extractListItem(line) !== void 0 || isTableStart(line, lines[index + 1]);
|
|
1550
1168
|
}
|
|
1551
1169
|
/**
|
|
1552
|
-
*
|
|
1170
|
+
* Resolves backslash escapes in a raw string to their literal characters - used for a
|
|
1553
1171
|
* link `href` (which is not otherwise inline-parsed) and any plain text run.
|
|
1554
1172
|
*
|
|
1555
1173
|
* @param text - The raw text possibly carrying `\x` escapes
|
|
@@ -1572,7 +1190,7 @@ function unescapeText(text) {
|
|
|
1572
1190
|
return out;
|
|
1573
1191
|
}
|
|
1574
1192
|
/**
|
|
1575
|
-
*
|
|
1193
|
+
* Merges adjacent text nodes into one - the inline scanner emits a text node per
|
|
1576
1194
|
* unrecognized character, so coalescing keeps the AST clean and assertion-friendly.
|
|
1577
1195
|
*
|
|
1578
1196
|
* @param nodes - The inline nodes (possibly with adjacent text runs)
|
|
@@ -1610,7 +1228,7 @@ function coalesceText(nodes, spans) {
|
|
|
1610
1228
|
return out;
|
|
1611
1229
|
}
|
|
1612
1230
|
/**
|
|
1613
|
-
*
|
|
1231
|
+
* Scans an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
|
|
1614
1232
|
* SAME length, the CommonMark rule that lets a span contain backticks). Returns the
|
|
1615
1233
|
* span's literal text + end index, or `undefined` when no matching closer exists (it
|
|
1616
1234
|
* then degrades to literal backticks).
|
|
@@ -1759,7 +1377,7 @@ function locateEmphasis(source, start, to) {
|
|
|
1759
1377
|
while (start + run < to && source[start + run] === marker && run < 2) run += 1;
|
|
1760
1378
|
const strong = run === 2;
|
|
1761
1379
|
const openEnd = start + run;
|
|
1762
|
-
if (openEnd >= to ||
|
|
1380
|
+
if (openEnd >= to || isFlankingWhitespace(source[openEnd] ?? "")) return void 0;
|
|
1763
1381
|
let index = openEnd;
|
|
1764
1382
|
while (index < to) {
|
|
1765
1383
|
const character = source[index] ?? "";
|
|
@@ -1782,7 +1400,7 @@ function locateEmphasis(source, start, to) {
|
|
|
1782
1400
|
if (character === marker) {
|
|
1783
1401
|
let closeRun = 0;
|
|
1784
1402
|
while (index + closeRun < to && source[index + closeRun] === marker) closeRun += 1;
|
|
1785
|
-
if (closeRun >= run && !
|
|
1403
|
+
if (closeRun >= run && !isFlankingWhitespace(source[index - 1] ?? "")) return {
|
|
1786
1404
|
strong,
|
|
1787
1405
|
open: openEnd,
|
|
1788
1406
|
close: index,
|
|
@@ -1829,7 +1447,7 @@ function scanEmphasis(source, start, to, depth = 0) {
|
|
|
1829
1447
|
};
|
|
1830
1448
|
}
|
|
1831
1449
|
/**
|
|
1832
|
-
*
|
|
1450
|
+
* Scans the window `[from, to)` of `source` into inline nodes - the single recursive
|
|
1833
1451
|
* engine the inline phase runs on (emphasis, link text, and image alternative
|
|
1834
1452
|
* content recurse through it). Linear:
|
|
1835
1453
|
* each character is consumed once; a failed construct emits its opening character as
|
|
@@ -1839,10 +1457,11 @@ function scanEmphasis(source, start, to, depth = 0) {
|
|
|
1839
1457
|
* @param from - The inclusive start of the scan window
|
|
1840
1458
|
* @param to - The exclusive end of the scan window
|
|
1841
1459
|
* @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
|
|
1842
|
-
* incremented by one on every recursive descent
|
|
1843
|
-
*
|
|
1844
|
-
*
|
|
1845
|
-
*
|
|
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.
|
|
1846
1465
|
* @returns The parsed inline nodes (NOT yet coalesced)
|
|
1847
1466
|
*
|
|
1848
1467
|
* @example
|
|
@@ -1868,7 +1487,9 @@ function scanInline(source, from, to, depth = 0) {
|
|
|
1868
1487
|
* @param from - The inclusive start of the scan window
|
|
1869
1488
|
* @param to - The exclusive end of the scan window
|
|
1870
1489
|
* @param spans - The operation-owned node span recorder
|
|
1871
|
-
* @param depth - The current inline-recursion depth
|
|
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
|
|
1872
1493
|
* @returns The parsed inline nodes before adjacent text coalescing
|
|
1873
1494
|
*
|
|
1874
1495
|
* @example
|
|
@@ -2210,7 +1831,7 @@ function collectList(lines, start, depth, spans = /* @__PURE__ */ new Map(), end
|
|
|
2210
1831
|
};
|
|
2211
1832
|
}
|
|
2212
1833
|
/**
|
|
2213
|
-
*
|
|
1834
|
+
* Projects a {@link MarkdownNode} into an unsanitized {@link HTMLDocument}.
|
|
2214
1835
|
*
|
|
2215
1836
|
* @remarks
|
|
2216
1837
|
* The projection is pure and iterative. Text and attribute values remain literal for
|
|
@@ -2539,29 +2160,7 @@ function markdownToHTML(node) {
|
|
|
2539
2160
|
};
|
|
2540
2161
|
}
|
|
2541
2162
|
/**
|
|
2542
|
-
*
|
|
2543
|
-
*
|
|
2544
|
-
* @remarks
|
|
2545
|
-
* Markdown widens `@orkestrel/html`'s attribute floor by exactly `src`, because image
|
|
2546
|
-
* syntax is meaningless without its source. `src` is still a URL attribute, so the
|
|
2547
|
-
* floor refuses `javascript:`, `data:`, `vbscript:`, and `file:` values. A stricter
|
|
2548
|
-
* consumer can compose {@link markdownToHTML} with `@orkestrel/html`'s `HTML` class
|
|
2549
|
-
* directly.
|
|
2550
|
-
*
|
|
2551
|
-
* @param node - The markdown document or bare node to render
|
|
2552
|
-
* @returns Sanitized canonical HTML
|
|
2553
|
-
*
|
|
2554
|
-
* @example
|
|
2555
|
-
* ```ts
|
|
2556
|
-
* renderHTML({ element: 'paragraph', children: [{ element: 'text', value: 'a & b' }] })
|
|
2557
|
-
* // '<p>a & b</p>'
|
|
2558
|
-
* ```
|
|
2559
|
-
*/
|
|
2560
|
-
function renderHTML(node) {
|
|
2561
|
-
return (0, _orkestrel_html.renderHTML)(new _orkestrel_html.HTML(markdownToHTML(node)).sanitize({ attributes: [..._orkestrel_html.SAFE_ATTRIBUTES, "src"] }).document);
|
|
2562
|
-
}
|
|
2563
|
-
/**
|
|
2564
|
-
* Render a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
|
|
2163
|
+
* Renders a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
|
|
2565
2164
|
* projection of `renderHTML`, and the serializer a `parse(renderMarkdown(doc))`
|
|
2566
2165
|
* round-trip is built on. Canonical forms: `*` / `**` emphasis at even emphasis
|
|
2567
2166
|
* nesting depths and `_` / `__` at odd depths, `- ` bullets, `N. ` sequential
|
|
@@ -2570,8 +2169,8 @@ function renderHTML(node) {
|
|
|
2570
2169
|
* `> `-prefixed blockquote lines, GFM tables (1-space-padded cells, `\|`-escaped
|
|
2571
2170
|
* pipes, an alignment delimiter row), `[text](href)` links, `` images,
|
|
2572
2171
|
* and two-space hard breaks. A `text` node's literal content is backslash-escaped
|
|
2573
|
-
* wherever it would otherwise re-parse as markup
|
|
2574
|
-
*
|
|
2172
|
+
* wherever it would otherwise re-parse as markup, so parsing the rendered source
|
|
2173
|
+
* returns the node it was rendered from.
|
|
2575
2174
|
*
|
|
2576
2175
|
* @remarks
|
|
2577
2176
|
* Total: never throws. At {@link MAX_DEPTH} a value-bearing node degrades to its
|
|
@@ -2856,32 +2455,62 @@ function renderMarkdown(node) {
|
|
|
2856
2455
|
return "";
|
|
2857
2456
|
}
|
|
2858
2457
|
/**
|
|
2859
|
-
*
|
|
2860
|
-
*
|
|
2861
|
-
* 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.
|
|
2862
2460
|
*
|
|
2863
2461
|
* @remarks
|
|
2864
|
-
*
|
|
2865
|
-
*
|
|
2866
|
-
* 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.
|
|
2867
2464
|
*
|
|
2868
|
-
* @param
|
|
2869
|
-
* @returns
|
|
2465
|
+
* @param parts - The projection fields to provide
|
|
2466
|
+
* @returns A complete invariant-preserving projection
|
|
2870
2467
|
*
|
|
2871
2468
|
* @example
|
|
2872
2469
|
* ```ts
|
|
2873
|
-
*
|
|
2470
|
+
* createProjection({
|
|
2471
|
+
* blocks: [{ element: 'thematicBreak' }],
|
|
2472
|
+
* inlines: [{ element: 'text', value: 'discarded' }],
|
|
2473
|
+
* })
|
|
2474
|
+
* // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
|
|
2874
2475
|
* ```
|
|
2875
2476
|
*/
|
|
2876
|
-
function
|
|
2877
|
-
const
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
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] = {
|
|
2513
|
+
element: "text",
|
|
2885
2514
|
value
|
|
2886
2515
|
};
|
|
2887
2516
|
}
|
|
@@ -2897,7 +2526,7 @@ function trimInlines(nodes) {
|
|
|
2897
2526
|
return out;
|
|
2898
2527
|
}
|
|
2899
2528
|
/**
|
|
2900
|
-
*
|
|
2529
|
+
* Reduces an inline run to the shape markdown can actually write back: adjacent text
|
|
2901
2530
|
* coalesced, empty text dropped, and every hard break either kept as a real line
|
|
2902
2531
|
* ending or spent as a space.
|
|
2903
2532
|
*
|
|
@@ -2910,8 +2539,8 @@ function trimInlines(nodes) {
|
|
|
2910
2539
|
* becomes the space it stood for.
|
|
2911
2540
|
*
|
|
2912
2541
|
* @param nodes - The inline run to normalize
|
|
2913
|
-
* @param breaks -
|
|
2914
|
-
*
|
|
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
|
|
2915
2544
|
* @returns The normalized run
|
|
2916
2545
|
*
|
|
2917
2546
|
* @example
|
|
@@ -2962,7 +2591,7 @@ function normalizeInlines(nodes, breaks) {
|
|
|
2962
2591
|
return coalesceText(out);
|
|
2963
2592
|
}
|
|
2964
2593
|
/**
|
|
2965
|
-
*
|
|
2594
|
+
* Combines the projections of one node's children into the projection of that node -
|
|
2966
2595
|
* the single place inline runs become paragraphs, so no ancestor has to decide it
|
|
2967
2596
|
* twice.
|
|
2968
2597
|
*
|
|
@@ -3049,7 +2678,7 @@ function mergeProjections(children) {
|
|
|
3049
2678
|
});
|
|
3050
2679
|
}
|
|
3051
2680
|
/**
|
|
3052
|
-
*
|
|
2681
|
+
* Reads a projection as BLOCK content - the view a document, a blockquote, and a list
|
|
3053
2682
|
* item each need.
|
|
3054
2683
|
*
|
|
3055
2684
|
* @remarks
|
|
@@ -3095,7 +2724,7 @@ function projectionToBlocks(projection) {
|
|
|
3095
2724
|
return blocks;
|
|
3096
2725
|
}
|
|
3097
2726
|
/**
|
|
3098
|
-
*
|
|
2727
|
+
* Reads a projection as INLINE content - the view a link, an emphasis, and a table cell
|
|
3099
2728
|
* each need.
|
|
3100
2729
|
*
|
|
3101
2730
|
* @remarks
|
|
@@ -3115,14 +2744,14 @@ function projectionToBlocks(projection) {
|
|
|
3115
2744
|
*/
|
|
3116
2745
|
function projectionToInlines(projection) {
|
|
3117
2746
|
if (!(0, _orkestrel_contract.isNonEmptyArray)(projection.blocks) && !(0, _orkestrel_contract.isNonEmptyArray)(projection.cells) && !(0, _orkestrel_contract.isNonEmptyArray)(projection.rows)) return coalesceText(projection.inlines);
|
|
3118
|
-
const value = projectionToBlocks(projection).map(flattenText).join(" ")
|
|
2747
|
+
const value = (0, _orkestrel_html.collapseSpace)(projectionToBlocks(projection).map(flattenText).join(" "));
|
|
3119
2748
|
return (0, _orkestrel_contract.isEmptyString)(value) ? [] : [{
|
|
3120
2749
|
element: "text",
|
|
3121
2750
|
value
|
|
3122
2751
|
}];
|
|
3123
2752
|
}
|
|
3124
2753
|
/**
|
|
3125
|
-
*
|
|
2754
|
+
* Projects one HTML leaf - a text node, a comment, or a doctype - to its
|
|
3126
2755
|
* {@link MarkdownProjection}.
|
|
3127
2756
|
*
|
|
3128
2757
|
* @remarks
|
|
@@ -3152,7 +2781,7 @@ function projectHTMLLeaf(leaf) {
|
|
|
3152
2781
|
});
|
|
3153
2782
|
}
|
|
3154
2783
|
/**
|
|
3155
|
-
*
|
|
2784
|
+
* Projects one HTML container - the document root or an element - from its children's
|
|
3156
2785
|
* already-computed projections. THE element mapping, and the only place that decides
|
|
3157
2786
|
* what an HTML tag becomes in markdown.
|
|
3158
2787
|
*
|
|
@@ -3301,7 +2930,7 @@ function projectHTMLNode(node, children) {
|
|
|
3301
2930
|
text: merged.text
|
|
3302
2931
|
});
|
|
3303
2932
|
case "img": {
|
|
3304
|
-
const alt = ((0, _orkestrel_html.attributeOf)(node, "alt") ?? "")
|
|
2933
|
+
const alt = (0, _orkestrel_html.collapseSpace)((0, _orkestrel_html.attributeOf)(node, "alt") ?? "");
|
|
3305
2934
|
return createProjection({
|
|
3306
2935
|
inlines: [{
|
|
3307
2936
|
element: "image",
|
|
@@ -3459,7 +3088,7 @@ function projectHTMLNode(node, children) {
|
|
|
3459
3088
|
return merged;
|
|
3460
3089
|
}
|
|
3461
3090
|
/**
|
|
3462
|
-
*
|
|
3091
|
+
* Projects an `@orkestrel/html` {@link HTMLNode} into a {@link MarkdownDocument} - the
|
|
3463
3092
|
* HTML→markdown direction, and the inverse of {@link markdownToHTML}.
|
|
3464
3093
|
*
|
|
3465
3094
|
* @remarks
|
|
@@ -3515,7 +3144,7 @@ function htmlToMarkdown(node) {
|
|
|
3515
3144
|
};
|
|
3516
3145
|
}
|
|
3517
3146
|
/**
|
|
3518
|
-
*
|
|
3147
|
+
* Walks a {@link MarkdownNode} depth-first, pre-order, root-inclusive - yields
|
|
3519
3148
|
* the node itself, then recurses into its children (block children, list items,
|
|
3520
3149
|
* image/link inline children, table header/row cells' inline nodes) in walk order.
|
|
3521
3150
|
*
|
|
@@ -3578,7 +3207,7 @@ function* walkNodes(node) {
|
|
|
3578
3207
|
}
|
|
3579
3208
|
}
|
|
3580
3209
|
/**
|
|
3581
|
-
*
|
|
3210
|
+
* Folds a {@link MarkdownNode} into a `T` through a total catamorphism - children are
|
|
3582
3211
|
* folded first (post-order), then the node's own {@link MarkdownHandler} is invoked
|
|
3583
3212
|
* with the already-folded children.
|
|
3584
3213
|
*
|
|
@@ -3595,13 +3224,13 @@ function* walkNodes(node) {
|
|
|
3595
3224
|
* with an empty children list instead of recursing further.
|
|
3596
3225
|
*
|
|
3597
3226
|
* @param node - The AST node to fold
|
|
3598
|
-
* @param handlers - The total {@link
|
|
3227
|
+
* @param handlers - The total {@link MarkdownHandlerMap} table, one handler per element
|
|
3599
3228
|
* @param depth - The starting recursion depth (pass `0` at the entry point)
|
|
3600
3229
|
* @returns The folded `T`
|
|
3601
3230
|
*
|
|
3602
3231
|
* @example
|
|
3603
3232
|
* ```ts
|
|
3604
|
-
* const countHandlers:
|
|
3233
|
+
* const countHandlers: MarkdownHandlerMap<number> = {
|
|
3605
3234
|
* document: (_, children) => children.reduce((a, b) => a + b, 1),
|
|
3606
3235
|
* // ...one handler per element, each summing its folded children
|
|
3607
3236
|
* }
|
|
@@ -3730,7 +3359,7 @@ function foldNode(node, handlers, depth) {
|
|
|
3730
3359
|
}
|
|
3731
3360
|
}
|
|
3732
3361
|
/**
|
|
3733
|
-
*
|
|
3362
|
+
* Rewrites a {@link MarkdownDocument} bottom-up (copy-on-write) - each node's children
|
|
3734
3363
|
* are rewritten first (post-order), then `rewrite` is applied to the node itself; the
|
|
3735
3364
|
* document ROOT is never passed to `rewrite` (the `element: 'document'` invariant
|
|
3736
3365
|
* always holds). A table's inline cells and a list's items ARE rewritten.
|
|
@@ -4013,7 +3642,7 @@ function rewriteDocument(document, rewrite) {
|
|
|
4013
3642
|
return [document, /* @__PURE__ */ new Map()];
|
|
4014
3643
|
}
|
|
4015
3644
|
/**
|
|
4016
|
-
*
|
|
3645
|
+
* Concatenates the `value` / `code` content of every descendant text / code-span /
|
|
4017
3646
|
* code-block node under `node`, including image alternative content, in walk order -
|
|
4018
3647
|
* the plain-text projection of an AST (search indexing, word counts, a text-only
|
|
4019
3648
|
* preview).
|
|
@@ -4086,6 +3715,458 @@ function flattenText(node) {
|
|
|
4086
3715
|
return value;
|
|
4087
3716
|
}
|
|
4088
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
|
|
4089
4170
|
exports.EMPTY_PROJECTION = EMPTY_PROJECTION;
|
|
4090
4171
|
exports.MAX_DEPTH = MAX_DEPTH;
|
|
4091
4172
|
exports.Markdown = Markdown;
|
|
@@ -4118,6 +4199,7 @@ exports.isEmphasisNode = isEmphasisNode;
|
|
|
4118
4199
|
exports.isEscapable = isEscapable;
|
|
4119
4200
|
exports.isFenceClose = isFenceClose;
|
|
4120
4201
|
exports.isFenceWhitespace = isFenceWhitespace;
|
|
4202
|
+
exports.isFlankingWhitespace = isFlankingWhitespace;
|
|
4121
4203
|
exports.isHeadingNode = isHeadingNode;
|
|
4122
4204
|
exports.isImageNode = isImageNode;
|
|
4123
4205
|
exports.isInlineNode = isInlineNode;
|
|
@@ -4133,7 +4215,6 @@ exports.isTableStart = isTableStart;
|
|
|
4133
4215
|
exports.isTextNode = isTextNode;
|
|
4134
4216
|
exports.isThematicBreak = isThematicBreak;
|
|
4135
4217
|
exports.isThematicBreakNode = isThematicBreakNode;
|
|
4136
|
-
exports.isWhitespace = isWhitespace;
|
|
4137
4218
|
exports.joinSources = joinSources;
|
|
4138
4219
|
exports.lineBreakShape = lineBreakShape;
|
|
4139
4220
|
exports.listItemMatchShape = listItemMatchShape;
|