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