@astryxdesign/core 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/dist/DateRangeInput/DateRangeInput.d.ts +3 -0
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +16 -9
- package/dist/Dialog/DialogHeader.d.ts +1 -1
- package/dist/Dialog/DialogHeader.d.ts.map +1 -1
- package/dist/Dialog/DialogHeader.js +10 -7
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +9 -3
- package/dist/Markdown/Markdown.d.ts +10 -2
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +58 -14
- package/dist/Markdown/index.d.ts +1 -1
- package/dist/Markdown/index.d.ts.map +1 -1
- package/dist/Markdown/parser.d.ts +126 -12
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +369 -34
- package/dist/Markdown/utils.d.ts +1 -1
- package/dist/Markdown/utils.d.ts.map +1 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +5 -2
- package/dist/Spinner/Spinner.d.ts +1 -1
- package/dist/Spinner/Spinner.d.ts.map +1 -1
- package/dist/Spinner/Spinner.js +23 -15
- package/dist/astryx.css +2 -1
- package/locales/en.json +16 -0
- package/locales/pseudo.json +12 -0
- package/package.json +6 -4
- package/scripts/agent-doc-state.mjs +1 -1
- package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
- package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
- package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
- package/src/DateRangeInput/DateRangeInput.tsx +29 -20
- package/src/Dialog/Dialog.doc.mjs +3 -0
- package/src/Dialog/Dialog.spec.md +1 -1
- package/src/Dialog/DialogHeader.doc.mjs +38 -0
- package/src/Dialog/DialogHeader.test.tsx +49 -0
- package/src/Dialog/DialogHeader.tsx +23 -4
- package/src/Dialog/modules/DialogHeader.spec.md +152 -0
- package/src/FileInput/FileInput.doc.mjs +2 -0
- package/src/FileInput/FileInput.spec.md +199 -0
- package/src/FileInput/FileInput.test.tsx +14 -0
- package/src/FileInput/FileInput.tsx +13 -3
- package/src/Markdown/Markdown.doc.mjs +167 -42
- package/src/Markdown/Markdown.public.test.ts +157 -0
- package/src/Markdown/Markdown.spec.md +149 -70
- package/src/Markdown/Markdown.test.tsx +107 -3
- package/src/Markdown/Markdown.tsx +116 -35
- package/src/Markdown/incremental.test.ts +175 -7
- package/src/Markdown/index.ts +6 -0
- package/src/Markdown/parser.perf.test.ts +3 -1
- package/src/Markdown/parser.test.ts +122 -0
- package/src/Markdown/parser.ts +609 -81
- package/src/Markdown/utils.ts +6 -0
- package/src/Slider/Slider.doc.mjs +16 -0
- package/src/Slider/Slider.spec.md +61 -47
- package/src/Slider/Slider.test.tsx +18 -0
- package/src/Slider/Slider.tsx +12 -6
- package/src/Spinner/Spinner.doc.mjs +6 -3
- package/src/Spinner/Spinner.test.tsx +37 -0
- package/src/Spinner/Spinner.tsx +31 -14
- package/src/theme/derivedVarRegistry.test.ts +6 -4
package/src/Markdown/parser.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @file parser.ts
|
|
5
5
|
* @input Markdown string
|
|
6
|
-
* @output Array of MarkdownNode AST nodes; heading slug
|
|
6
|
+
* @output Array of MarkdownNode AST nodes, including opt-in math; heading slug
|
|
7
|
+
* helpers
|
|
7
8
|
* (inlineText, slugify, uniqueSlug) shared by Markdown rendering and
|
|
8
9
|
* Outline's parseOutlineFromMarkdown
|
|
9
10
|
* @position Core parser; consumed by Markdown.tsx and Outline
|
|
@@ -13,6 +14,7 @@
|
|
|
13
14
|
// Types
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
|
|
17
|
+
/** Nodes returned by default and legacy parser calls. */
|
|
16
18
|
export type InlineNode =
|
|
17
19
|
| {type: 'text'; content: string}
|
|
18
20
|
| {type: 'bold'; children: InlineNode[]}
|
|
@@ -24,7 +26,23 @@ export type InlineNode =
|
|
|
24
26
|
| {type: 'citation'; sourceId: string}
|
|
25
27
|
| {type: 'break'};
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
/** The additional inline node returned only when parsing with `math: true`. */
|
|
30
|
+
export type MathInlineNode = {type: 'math'; value: string};
|
|
31
|
+
|
|
32
|
+
/** Nodes returned by an explicitly math-enabled inline parse. */
|
|
33
|
+
export type InlineNodeWithMath =
|
|
34
|
+
| {type: 'text'; content: string}
|
|
35
|
+
| {type: 'bold'; children: InlineNodeWithMath[]}
|
|
36
|
+
| {type: 'italic'; children: InlineNodeWithMath[]}
|
|
37
|
+
| {type: 'strikethrough'; children: InlineNodeWithMath[]}
|
|
38
|
+
| {type: 'code'; content: string}
|
|
39
|
+
| MathInlineNode
|
|
40
|
+
| {type: 'link'; href: string; children: InlineNodeWithMath[]}
|
|
41
|
+
| {type: 'image'; src: string; alt: string}
|
|
42
|
+
| {type: 'citation'; sourceId: string}
|
|
43
|
+
| {type: 'break'};
|
|
44
|
+
|
|
45
|
+
type BlockMetadata = {
|
|
28
46
|
/**
|
|
29
47
|
* Where this block came from in the source, when parsed with the
|
|
30
48
|
* `sourceRanges` option. Top-level blocks only.
|
|
@@ -32,7 +50,7 @@ export type BlockNode = BlockNodeKind & {
|
|
|
32
50
|
range?: SourceRange;
|
|
33
51
|
};
|
|
34
52
|
|
|
35
|
-
type
|
|
53
|
+
type LegacyBlockNodeKind =
|
|
36
54
|
| {type: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6; children: InlineNode[]}
|
|
37
55
|
| {type: 'paragraph'; children: InlineNode[]}
|
|
38
56
|
| {type: 'codeblock'; language: string; content: string}
|
|
@@ -55,6 +73,43 @@ type BlockNodeKind =
|
|
|
55
73
|
| {type: 'hr'}
|
|
56
74
|
| {type: 'image'; src: string; alt: string};
|
|
57
75
|
|
|
76
|
+
/** Blocks returned by default and legacy parser calls. */
|
|
77
|
+
export type BlockNode = LegacyBlockNodeKind & BlockMetadata;
|
|
78
|
+
|
|
79
|
+
/** The additional block returned only when parsing with `math: true`. */
|
|
80
|
+
export type MathBlockNode = {type: 'math'; value: string} & BlockMetadata;
|
|
81
|
+
|
|
82
|
+
type MathEnabledBlockNodeKind =
|
|
83
|
+
| {
|
|
84
|
+
type: 'heading';
|
|
85
|
+
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
86
|
+
children: InlineNodeWithMath[];
|
|
87
|
+
}
|
|
88
|
+
| {type: 'paragraph'; children: InlineNodeWithMath[]}
|
|
89
|
+
| {type: 'codeblock'; language: string; content: string}
|
|
90
|
+
| MathBlockNode
|
|
91
|
+
| {type: 'blockquote'; children: BlockNodeWithMath[]}
|
|
92
|
+
| {
|
|
93
|
+
type: 'list';
|
|
94
|
+
ordered: boolean;
|
|
95
|
+
start?: number;
|
|
96
|
+
/** Ordered-list marker delimiter ('.' or ')'). Undefined for bullets. */
|
|
97
|
+
delimiter?: '.' | ')';
|
|
98
|
+
loose?: boolean;
|
|
99
|
+
items: ListItemNodeWithMath[];
|
|
100
|
+
}
|
|
101
|
+
| {
|
|
102
|
+
type: 'table';
|
|
103
|
+
headers: TableCellNodeWithMath[];
|
|
104
|
+
alignments: TableAlignment[];
|
|
105
|
+
rows: TableCellNodeWithMath[][];
|
|
106
|
+
}
|
|
107
|
+
| {type: 'hr'}
|
|
108
|
+
| {type: 'image'; src: string; alt: string};
|
|
109
|
+
|
|
110
|
+
/** Blocks returned by an explicitly math-enabled block parse. */
|
|
111
|
+
export type BlockNodeWithMath = MathEnabledBlockNodeKind & BlockMetadata;
|
|
112
|
+
|
|
58
113
|
/**
|
|
59
114
|
* Where a block sits in the source string handed to `parseMarkdown`:
|
|
60
115
|
* `source.slice(start, end)` is the block, and `end` excludes the block's
|
|
@@ -67,7 +122,12 @@ type BlockNodeKind =
|
|
|
67
122
|
export type SourceRange = {readonly start: number; readonly end: number};
|
|
68
123
|
|
|
69
124
|
export type ListItemNode = {checked?: boolean; children: BlockNode[]};
|
|
125
|
+
type ListItemNodeWithMath = {
|
|
126
|
+
checked?: boolean;
|
|
127
|
+
children: BlockNodeWithMath[];
|
|
128
|
+
};
|
|
70
129
|
export type TableCellNode = {children: InlineNode[]};
|
|
130
|
+
type TableCellNodeWithMath = {children: InlineNodeWithMath[]};
|
|
71
131
|
export type TableAlignment = 'left' | 'center' | 'right' | null;
|
|
72
132
|
|
|
73
133
|
// ---------------------------------------------------------------------------
|
|
@@ -81,7 +141,7 @@ export type TableAlignment = 'left' | 'center' | 'right' | null;
|
|
|
81
141
|
* `parseMarkdownIncremental` functions also accept the legacy
|
|
82
142
|
* `ReadonlySet<string>` shape as the second argument.
|
|
83
143
|
*/
|
|
84
|
-
|
|
144
|
+
type CommonParseOptions = {
|
|
85
145
|
/** Set of citation source ids — `[id]` / `【id】` markers in this set
|
|
86
146
|
* become citation nodes instead of plain text / links. */
|
|
87
147
|
sourceIds?: ReadonlySet<string>;
|
|
@@ -109,9 +169,22 @@ export type ParseOptions = {
|
|
|
109
169
|
sourceRanges?: boolean;
|
|
110
170
|
};
|
|
111
171
|
|
|
172
|
+
/** Options for default and legacy parser results. */
|
|
173
|
+
export type ParseOptions = CommonParseOptions & {math?: false | undefined};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Options that explicitly parse `$…$` and `$$…$$` into math-enabled result
|
|
177
|
+
* unions. Keeping this separate prevents a legacy `ParseOptions` annotation
|
|
178
|
+
* from silently widening an exhaustive node switch.
|
|
179
|
+
*/
|
|
180
|
+
export type MathParseOptions = CommonParseOptions & {math: true};
|
|
181
|
+
|
|
182
|
+
type RuntimeParseOptions = CommonParseOptions & {math?: boolean};
|
|
183
|
+
|
|
112
184
|
type ResolvedOptions = {
|
|
113
185
|
readonly sourceIds: ReadonlySet<string> | undefined;
|
|
114
186
|
readonly autolink: 'gfm' | undefined;
|
|
187
|
+
readonly math?: boolean;
|
|
115
188
|
readonly sourceRanges?: boolean;
|
|
116
189
|
/**
|
|
117
190
|
* Offset of this parse's input within the document the ranges are reported
|
|
@@ -131,7 +204,7 @@ type ResolvedOptions = {
|
|
|
131
204
|
const EMPTY_OPTS: ResolvedOptions = {sourceIds: undefined, autolink: undefined};
|
|
132
205
|
|
|
133
206
|
function resolveOptions(
|
|
134
|
-
arg: ReadonlySet<string> |
|
|
207
|
+
arg: ReadonlySet<string> | RuntimeParseOptions | undefined,
|
|
135
208
|
): ResolvedOptions {
|
|
136
209
|
if (arg == null) {
|
|
137
210
|
return EMPTY_OPTS;
|
|
@@ -144,10 +217,11 @@ function resolveOptions(
|
|
|
144
217
|
if (typeof (arg as {has?: unknown}).has === 'function') {
|
|
145
218
|
return {sourceIds: arg as ReadonlySet<string>, autolink: undefined};
|
|
146
219
|
}
|
|
147
|
-
const opts = arg as
|
|
220
|
+
const opts = arg as RuntimeParseOptions;
|
|
148
221
|
return {
|
|
149
222
|
sourceIds: opts.sourceIds,
|
|
150
223
|
autolink: opts.autolink,
|
|
224
|
+
math: opts.math === true ? true : undefined,
|
|
151
225
|
sourceRanges: opts.sourceRanges,
|
|
152
226
|
};
|
|
153
227
|
}
|
|
@@ -175,6 +249,149 @@ function normalizeLinkLabel(label: string): string {
|
|
|
175
249
|
return label.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
176
250
|
}
|
|
177
251
|
|
|
252
|
+
type DisplayMathContainer = {
|
|
253
|
+
outerQuoteDepth: number;
|
|
254
|
+
listBaseIndent: number | null;
|
|
255
|
+
innerQuoteDepth: number;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
type DisplayMathMatch = {
|
|
259
|
+
value: string;
|
|
260
|
+
nextIndex: number;
|
|
261
|
+
endLine: number;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
function stripBlockquoteMarkers(line: string): {
|
|
265
|
+
content: string;
|
|
266
|
+
quoteDepth: number;
|
|
267
|
+
} {
|
|
268
|
+
let content = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
269
|
+
let quoteDepth = 0;
|
|
270
|
+
while (true) {
|
|
271
|
+
const marker = /^ {0,3}> ?/.exec(content);
|
|
272
|
+
if (marker == null) {
|
|
273
|
+
return {content, quoteDepth};
|
|
274
|
+
}
|
|
275
|
+
content = content.slice(marker[0].length);
|
|
276
|
+
quoteDepth++;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function stripExactBlockquoteDepth(
|
|
281
|
+
line: string,
|
|
282
|
+
quoteDepth: number,
|
|
283
|
+
): string | null {
|
|
284
|
+
let content = line;
|
|
285
|
+
for (let depth = 0; depth < quoteDepth; depth++) {
|
|
286
|
+
const marker = /^ {0,3}> ?/.exec(content);
|
|
287
|
+
if (marker == null) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
content = content.slice(marker[0].length);
|
|
291
|
+
}
|
|
292
|
+
return content;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Recognize a standalone display-math marker at the current container boundary.
|
|
297
|
+
* A list opener carries its base indent so an indented continuation marker can
|
|
298
|
+
* close it; blockquotes must keep the same quote depth.
|
|
299
|
+
*/
|
|
300
|
+
function displayMathContainer(line: string): DisplayMathContainer | null {
|
|
301
|
+
const outer = stripBlockquoteMarkers(line);
|
|
302
|
+
const listMarker = /^( {0,9})(?:[-*+]|\d+[.)]) +(.*)$/.exec(outer.content);
|
|
303
|
+
if (listMarker != null) {
|
|
304
|
+
const taskMarker = /^\[[ xX]\] +(.*)$/.exec(listMarker[2]);
|
|
305
|
+
const inner = stripBlockquoteMarkers(taskMarker?.[1] ?? listMarker[2]);
|
|
306
|
+
return inner.content.trim() === '$$'
|
|
307
|
+
? {
|
|
308
|
+
outerQuoteDepth: outer.quoteDepth,
|
|
309
|
+
listBaseIndent: listMarker[1].length,
|
|
310
|
+
innerQuoteDepth: inner.quoteDepth,
|
|
311
|
+
}
|
|
312
|
+
: null;
|
|
313
|
+
}
|
|
314
|
+
return outer.content.trim() === '$$'
|
|
315
|
+
? {
|
|
316
|
+
outerQuoteDepth: outer.quoteDepth,
|
|
317
|
+
listBaseIndent: null,
|
|
318
|
+
innerQuoteDepth: 0,
|
|
319
|
+
}
|
|
320
|
+
: null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
type DisplayMathLineState = 'close' | 'inside' | 'outside';
|
|
324
|
+
|
|
325
|
+
function displayMathLineState(
|
|
326
|
+
line: string,
|
|
327
|
+
container: DisplayMathContainer,
|
|
328
|
+
): DisplayMathLineState {
|
|
329
|
+
const outerContent = stripExactBlockquoteDepth(
|
|
330
|
+
line,
|
|
331
|
+
container.outerQuoteDepth,
|
|
332
|
+
);
|
|
333
|
+
if (outerContent == null) {
|
|
334
|
+
return 'outside';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (container.listBaseIndent == null) {
|
|
338
|
+
// A deeper quote starts a different container. It cannot close or continue
|
|
339
|
+
// the math expression owned by the shallower quote.
|
|
340
|
+
if (/^ {0,3}> ?/.test(outerContent)) {
|
|
341
|
+
return 'outside';
|
|
342
|
+
}
|
|
343
|
+
return outerContent.trim() === '$$' ? 'close' : 'inside';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const continuationIndent =
|
|
347
|
+
outerContent.length - outerContent.trimStart().length;
|
|
348
|
+
if (continuationIndent <= container.listBaseIndent) {
|
|
349
|
+
return 'outside';
|
|
350
|
+
}
|
|
351
|
+
const innerContent = stripExactBlockquoteDepth(
|
|
352
|
+
outerContent.trimStart(),
|
|
353
|
+
container.innerQuoteDepth,
|
|
354
|
+
);
|
|
355
|
+
if (innerContent == null || /^ {0,3}> ?/.test(innerContent)) {
|
|
356
|
+
return 'outside';
|
|
357
|
+
}
|
|
358
|
+
return innerContent.trim() === '$$' ? 'close' : 'inside';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Match a complete `$$…$$` display-math block without consuming partial input. */
|
|
362
|
+
function matchDisplayMathBlock(
|
|
363
|
+
lines: string[],
|
|
364
|
+
lineIndex: number,
|
|
365
|
+
): DisplayMathMatch | null {
|
|
366
|
+
const trimmed = lines[lineIndex].trim();
|
|
367
|
+
if (
|
|
368
|
+
trimmed.length > 4 &&
|
|
369
|
+
trimmed.startsWith('$$') &&
|
|
370
|
+
trimmed.endsWith('$$')
|
|
371
|
+
) {
|
|
372
|
+
const value = trimmed.slice(2, -2);
|
|
373
|
+
return value.trim() === ''
|
|
374
|
+
? null
|
|
375
|
+
: {value, nextIndex: lineIndex + 1, endLine: lineIndex};
|
|
376
|
+
}
|
|
377
|
+
if (trimmed !== '$$') {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
for (let index = lineIndex + 1; index < lines.length; index++) {
|
|
381
|
+
if (lines[index].trim() === '$$') {
|
|
382
|
+
const value = lines.slice(lineIndex + 1, index).join('\n');
|
|
383
|
+
return value.trim() === ''
|
|
384
|
+
? null
|
|
385
|
+
: {
|
|
386
|
+
value,
|
|
387
|
+
nextIndex: index + 1,
|
|
388
|
+
endLine: index,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
178
395
|
function matchLinkDefinition(
|
|
179
396
|
line: string,
|
|
180
397
|
): {label: string; destination: string; hasTitle: boolean} | null {
|
|
@@ -211,7 +428,10 @@ function matchLinkDefinition(
|
|
|
211
428
|
* where every definition is global. Separating a footer definition block with
|
|
212
429
|
* a blank line — the usual form — always works.
|
|
213
430
|
*/
|
|
214
|
-
function extractLinkDefinitions(
|
|
431
|
+
function extractLinkDefinitions(
|
|
432
|
+
input: string,
|
|
433
|
+
math = false,
|
|
434
|
+
): {
|
|
215
435
|
defs: ReadonlyMap<string, string>;
|
|
216
436
|
cleaned: string;
|
|
217
437
|
/**
|
|
@@ -240,6 +460,16 @@ function extractLinkDefinitions(input: string): {
|
|
|
240
460
|
}
|
|
241
461
|
continue;
|
|
242
462
|
}
|
|
463
|
+
if (math) {
|
|
464
|
+
const displayMath = matchDisplayMathBlock(lines, index);
|
|
465
|
+
if (displayMath != null) {
|
|
466
|
+
// Math is opaque Markdown content: definition-shaped TeX must not leak
|
|
467
|
+
// into the document-wide link-definition map.
|
|
468
|
+
index = displayMath.endLine;
|
|
469
|
+
atBoundary = true;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
243
473
|
const fenceMatch = line.match(/^(`{3,}|~{3,})/);
|
|
244
474
|
if (fenceMatch) {
|
|
245
475
|
inFence = true;
|
|
@@ -312,7 +542,7 @@ function matchReferenceLink(
|
|
|
312
542
|
start: number,
|
|
313
543
|
linkDefs: ReadonlyMap<string, string>,
|
|
314
544
|
opts: ResolvedOptions,
|
|
315
|
-
): {node:
|
|
545
|
+
): {node: InlineNodeWithMath; end: number} | null {
|
|
316
546
|
const textClose = text.indexOf(']', start + 1);
|
|
317
547
|
if (textClose === -1) {
|
|
318
548
|
return null;
|
|
@@ -357,7 +587,7 @@ function matchReferenceImage(
|
|
|
357
587
|
text: string,
|
|
358
588
|
start: number,
|
|
359
589
|
linkDefs: ReadonlyMap<string, string>,
|
|
360
|
-
): {node:
|
|
590
|
+
): {node: InlineNodeWithMath; end: number} | null {
|
|
361
591
|
const altClose = text.indexOf(']', start + 2);
|
|
362
592
|
if (altClose === -1) {
|
|
363
593
|
return null;
|
|
@@ -412,6 +642,64 @@ function isWordChar(ch: string | undefined): boolean {
|
|
|
412
642
|
return /\w/.test(ch);
|
|
413
643
|
}
|
|
414
644
|
|
|
645
|
+
/** True when the character at `index` is preceded by an odd backslash run. */
|
|
646
|
+
function isEscaped(text: string, index: number): boolean {
|
|
647
|
+
let backslashes = 0;
|
|
648
|
+
for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
|
|
649
|
+
backslashes++;
|
|
650
|
+
}
|
|
651
|
+
return backslashes % 2 === 1;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Find the closing delimiter for `$…$` math on the same line.
|
|
656
|
+
*
|
|
657
|
+
* The whitespace and numeric-edge rules mirror common dollar-math parsers:
|
|
658
|
+
* whitespace cannot hug the delimiters, a digit cannot sit immediately before
|
|
659
|
+
* the opener or after the closer, and `$$` is reserved for display math. The
|
|
660
|
+
* numeric guard prevents ordinary prose such as "$20 and $30" from becoming a
|
|
661
|
+
* formula even in a math-enabled document.
|
|
662
|
+
*/
|
|
663
|
+
function isInlineMathStart(text: string, index: number): boolean {
|
|
664
|
+
return (
|
|
665
|
+
text[index] === '$' &&
|
|
666
|
+
text[index - 1] !== '$' &&
|
|
667
|
+
text[index + 1] !== '$' &&
|
|
668
|
+
text[index + 1] != null &&
|
|
669
|
+
!/\s/.test(text[index + 1]) &&
|
|
670
|
+
!/\d/.test(text[index - 1] ?? '') &&
|
|
671
|
+
!isEscaped(text, index)
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function findInlineMathEnd(text: string, start: number): number {
|
|
676
|
+
if (!isInlineMathStart(text, start)) {
|
|
677
|
+
return -1;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
for (let index = start + 1; index < text.length; index++) {
|
|
681
|
+
if (text[index] === '\n') {
|
|
682
|
+
return -1;
|
|
683
|
+
}
|
|
684
|
+
if (text[index] !== '$' || isEscaped(text, index)) {
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
// An unescaped dollar ends this candidate: it either forms a valid closer
|
|
688
|
+
// or makes the whole span literal. Never skip over one and pair with a
|
|
689
|
+
// later dollar, which would swallow currency or another expression.
|
|
690
|
+
if (
|
|
691
|
+
text[index - 1] === '$' ||
|
|
692
|
+
text[index + 1] === '$' ||
|
|
693
|
+
/\s/.test(text[index - 1]) ||
|
|
694
|
+
/\d/.test(text[index + 1] ?? '')
|
|
695
|
+
) {
|
|
696
|
+
return -1;
|
|
697
|
+
}
|
|
698
|
+
return index;
|
|
699
|
+
}
|
|
700
|
+
return -1;
|
|
701
|
+
}
|
|
702
|
+
|
|
415
703
|
// ---------------------------------------------------------------------------
|
|
416
704
|
// URL scheme sanitization
|
|
417
705
|
// ---------------------------------------------------------------------------
|
|
@@ -494,11 +782,15 @@ export function parseInline(
|
|
|
494
782
|
text: string,
|
|
495
783
|
sourceIds?: ReadonlySet<string>,
|
|
496
784
|
): InlineNode[];
|
|
785
|
+
export function parseInline(
|
|
786
|
+
text: string,
|
|
787
|
+
options: MathParseOptions,
|
|
788
|
+
): InlineNodeWithMath[];
|
|
497
789
|
export function parseInline(text: string, options: ParseOptions): InlineNode[];
|
|
498
790
|
export function parseInline(
|
|
499
791
|
text: string,
|
|
500
|
-
arg?: ReadonlySet<string> |
|
|
501
|
-
):
|
|
792
|
+
arg?: ReadonlySet<string> | RuntimeParseOptions,
|
|
793
|
+
): InlineNodeWithMath[] {
|
|
502
794
|
return parseInlineEntry(text, resolveOptions(arg));
|
|
503
795
|
}
|
|
504
796
|
|
|
@@ -509,15 +801,21 @@ export function parseInline(
|
|
|
509
801
|
* wrapper and call `parseInlineImpl` directly so the transform runs only on
|
|
510
802
|
* the outermost block's inline tree — letting `transformAutolinks` decide
|
|
511
803
|
* which subtrees to descend into (text, bold, italic, strikethrough) and
|
|
512
|
-
* which to skip (link, code, image, citation, break).
|
|
804
|
+
* which to skip (link, code, math, image, citation, break).
|
|
513
805
|
*/
|
|
514
|
-
function parseInlineEntry(
|
|
806
|
+
function parseInlineEntry(
|
|
807
|
+
text: string,
|
|
808
|
+
opts: ResolvedOptions,
|
|
809
|
+
): InlineNodeWithMath[] {
|
|
515
810
|
const nodes = parseInlineImpl(text, opts);
|
|
516
811
|
return opts.autolink === 'gfm' ? transformAutolinks(nodes) : nodes;
|
|
517
812
|
}
|
|
518
813
|
|
|
519
|
-
function parseInlineImpl(
|
|
520
|
-
|
|
814
|
+
function parseInlineImpl(
|
|
815
|
+
text: string,
|
|
816
|
+
opts: ResolvedOptions,
|
|
817
|
+
): InlineNodeWithMath[] {
|
|
818
|
+
const nodes: InlineNodeWithMath[] = [];
|
|
521
819
|
let i = 0;
|
|
522
820
|
|
|
523
821
|
while (i < text.length) {
|
|
@@ -540,6 +838,16 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
|
|
|
540
838
|
}
|
|
541
839
|
}
|
|
542
840
|
|
|
841
|
+
// --- Inline math (opt-in; code takes precedence) ---
|
|
842
|
+
if (opts.math && text[i] === '$') {
|
|
843
|
+
const closeIndex = findInlineMathEnd(text, i);
|
|
844
|
+
if (closeIndex !== -1) {
|
|
845
|
+
nodes.push({type: 'math', value: text.slice(i + 1, closeIndex)});
|
|
846
|
+
i = closeIndex + 1;
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
543
851
|
// --- Citation: fullwidth 【id】 ---
|
|
544
852
|
{
|
|
545
853
|
const citation = matchFullwidthCitation(text, i, opts);
|
|
@@ -718,7 +1026,11 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
|
|
|
718
1026
|
|
|
719
1027
|
// --- Plain text (with line-break detection) ---
|
|
720
1028
|
let end = i + 1;
|
|
721
|
-
while (
|
|
1029
|
+
while (
|
|
1030
|
+
end < text.length &&
|
|
1031
|
+
!'*_~`[!\\\n\u3010'.includes(text[end]) &&
|
|
1032
|
+
!(opts.math && text[end] === '$')
|
|
1033
|
+
) {
|
|
722
1034
|
end++;
|
|
723
1035
|
}
|
|
724
1036
|
|
|
@@ -991,12 +1303,12 @@ function scanAutolinksInText(text: string): AutolinkMatch[] {
|
|
|
991
1303
|
* Split a text-node `content` string into a sequence of text + link nodes
|
|
992
1304
|
* based on autolink matches.
|
|
993
1305
|
*/
|
|
994
|
-
function splitTextOnAutolinks(content: string):
|
|
1306
|
+
function splitTextOnAutolinks(content: string): InlineNodeWithMath[] {
|
|
995
1307
|
const matches = scanAutolinksInText(content);
|
|
996
1308
|
if (matches.length === 0) {
|
|
997
1309
|
return [{type: 'text', content}];
|
|
998
1310
|
}
|
|
999
|
-
const out:
|
|
1311
|
+
const out: InlineNodeWithMath[] = [];
|
|
1000
1312
|
let cursor = 0;
|
|
1001
1313
|
for (const m of matches) {
|
|
1002
1314
|
if (m.start > cursor) {
|
|
@@ -1023,8 +1335,8 @@ function splitTextOnAutolinks(content: string): InlineNode[] {
|
|
|
1023
1335
|
* `image` alt text, `citation`, or `break`. Runs only on the outermost
|
|
1024
1336
|
* block's inline tree (see `parseInlineEntry`).
|
|
1025
1337
|
*/
|
|
1026
|
-
function transformAutolinks(nodes:
|
|
1027
|
-
const out:
|
|
1338
|
+
function transformAutolinks(nodes: InlineNodeWithMath[]): InlineNodeWithMath[] {
|
|
1339
|
+
const out: InlineNodeWithMath[] = [];
|
|
1028
1340
|
for (const node of nodes) {
|
|
1029
1341
|
if (node.type === 'text') {
|
|
1030
1342
|
const split = splitTextOnAutolinks(node.content);
|
|
@@ -1198,8 +1510,8 @@ function parseTable(
|
|
|
1198
1510
|
lines: string[],
|
|
1199
1511
|
lineIndex: number,
|
|
1200
1512
|
opts: ResolvedOptions,
|
|
1201
|
-
): {node:
|
|
1202
|
-
const headers:
|
|
1513
|
+
): {node: BlockNodeWithMath; nextIndex: number} {
|
|
1514
|
+
const headers: TableCellNodeWithMath[] = splitTableRow(lines[lineIndex]).map(
|
|
1203
1515
|
cell => ({children: parseInlineEntry(cell, opts)}),
|
|
1204
1516
|
);
|
|
1205
1517
|
const alignments: TableAlignment[] = splitTableRow(lines[lineIndex + 1]).map(
|
|
@@ -1216,7 +1528,7 @@ function parseTable(
|
|
|
1216
1528
|
: null;
|
|
1217
1529
|
},
|
|
1218
1530
|
);
|
|
1219
|
-
const rows:
|
|
1531
|
+
const rows: TableCellNodeWithMath[][] = [];
|
|
1220
1532
|
let rowIndex = lineIndex + 2;
|
|
1221
1533
|
while (
|
|
1222
1534
|
rowIndex < lines.length &&
|
|
@@ -1241,8 +1553,8 @@ function parseList(
|
|
|
1241
1553
|
startIndex: number,
|
|
1242
1554
|
ordered: boolean,
|
|
1243
1555
|
opts: ResolvedOptions,
|
|
1244
|
-
): {node:
|
|
1245
|
-
const items:
|
|
1556
|
+
): {node: BlockNodeWithMath; nextIndex: number} {
|
|
1557
|
+
const items: ListItemNodeWithMath[] = [];
|
|
1246
1558
|
const baseIndent = getIndent(lines[startIndex]);
|
|
1247
1559
|
// Ordered lists may use either '.' or ')' as the marker delimiter
|
|
1248
1560
|
// (CommonMark 5.2). Capture which one this list starts with so its items
|
|
@@ -1335,21 +1647,25 @@ export function parseMarkdown(
|
|
|
1335
1647
|
input: string,
|
|
1336
1648
|
sourceIds?: ReadonlySet<string>,
|
|
1337
1649
|
): BlockNode[];
|
|
1650
|
+
export function parseMarkdown(
|
|
1651
|
+
input: string,
|
|
1652
|
+
options: MathParseOptions,
|
|
1653
|
+
): BlockNodeWithMath[];
|
|
1338
1654
|
export function parseMarkdown(
|
|
1339
1655
|
input: string,
|
|
1340
1656
|
options: ParseOptions,
|
|
1341
1657
|
): BlockNode[];
|
|
1342
1658
|
export function parseMarkdown(
|
|
1343
1659
|
input: string,
|
|
1344
|
-
arg?: ReadonlySet<string> |
|
|
1345
|
-
):
|
|
1660
|
+
arg?: ReadonlySet<string> | RuntimeParseOptions,
|
|
1661
|
+
): BlockNodeWithMath[] {
|
|
1346
1662
|
return parseMarkdownImpl(input, resolveOptions(arg));
|
|
1347
1663
|
}
|
|
1348
1664
|
|
|
1349
1665
|
function parseMarkdownImpl(
|
|
1350
1666
|
input: string,
|
|
1351
1667
|
baseOpts: ResolvedOptions,
|
|
1352
|
-
):
|
|
1668
|
+
): BlockNodeWithMath[] {
|
|
1353
1669
|
// Collect this input's link reference definitions and strip their lines,
|
|
1354
1670
|
// then merge them with any definitions inherited from an enclosing parse
|
|
1355
1671
|
// (the incremental parser passes the whole document's definitions in; a
|
|
@@ -1357,7 +1673,7 @@ function parseMarkdownImpl(
|
|
|
1357
1673
|
// definitions win on conflict, matching CommonMark's first-definition-wins
|
|
1358
1674
|
// in document order; locally-nested definitions still resolve within this
|
|
1359
1675
|
// parse.
|
|
1360
|
-
const {defs, cleaned, lineMap} = extractLinkDefinitions(input);
|
|
1676
|
+
const {defs, cleaned, lineMap} = extractLinkDefinitions(input, baseOpts.math);
|
|
1361
1677
|
const inherited = baseOpts.linkDefs;
|
|
1362
1678
|
let linkDefs: ReadonlyMap<string, string> | undefined;
|
|
1363
1679
|
if (defs.size === 0) {
|
|
@@ -1370,7 +1686,7 @@ function parseMarkdownImpl(
|
|
|
1370
1686
|
const opts: ResolvedOptions =
|
|
1371
1687
|
linkDefs != null ? {...baseOpts, linkDefs} : baseOpts;
|
|
1372
1688
|
const lines = cleaned.split('\n');
|
|
1373
|
-
const blocks:
|
|
1689
|
+
const blocks: BlockNodeWithMath[] = [];
|
|
1374
1690
|
// The line each block started on, parallel to `blocks`. Only collected when
|
|
1375
1691
|
// ranges were asked for; a block's end is resolved after the loop, since the
|
|
1376
1692
|
// branch that produced it has already moved `index` past whatever it read.
|
|
@@ -1381,7 +1697,7 @@ function parseMarkdownImpl(
|
|
|
1381
1697
|
? []
|
|
1382
1698
|
: null;
|
|
1383
1699
|
let blockStartLine = 0;
|
|
1384
|
-
const pushBlock = (node:
|
|
1700
|
+
const pushBlock = (node: BlockNodeWithMath, endLine?: number) => {
|
|
1385
1701
|
blocks.push(node);
|
|
1386
1702
|
blockStartLines?.push(blockStartLine);
|
|
1387
1703
|
blockEndLines?.push(endLine);
|
|
@@ -1418,6 +1734,19 @@ function parseMarkdownImpl(
|
|
|
1418
1734
|
continue;
|
|
1419
1735
|
}
|
|
1420
1736
|
|
|
1737
|
+
// --- Display math (opt-in; fenced code takes precedence) ---
|
|
1738
|
+
if (opts.math) {
|
|
1739
|
+
const displayMath = matchDisplayMathBlock(lines, index);
|
|
1740
|
+
if (displayMath != null) {
|
|
1741
|
+
pushBlock(
|
|
1742
|
+
{type: 'math', value: displayMath.value},
|
|
1743
|
+
displayMath.endLine,
|
|
1744
|
+
);
|
|
1745
|
+
index = displayMath.nextIndex;
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1421
1750
|
// --- Heading ---
|
|
1422
1751
|
const headingMatch = line.match(/^(#{1,6}) +(.*)/);
|
|
1423
1752
|
if (headingMatch) {
|
|
@@ -1502,6 +1831,7 @@ function parseMarkdownImpl(
|
|
|
1502
1831
|
while (
|
|
1503
1832
|
index < lines.length &&
|
|
1504
1833
|
!isBlockStart(lines[index]) &&
|
|
1834
|
+
!(opts.math && matchDisplayMathBlock(lines, index) != null) &&
|
|
1505
1835
|
lines[index].trim() !== ''
|
|
1506
1836
|
) {
|
|
1507
1837
|
paraLines.push(lines[index]);
|
|
@@ -1536,7 +1866,7 @@ function parseMarkdownImpl(
|
|
|
1536
1866
|
* `lineMap` says which input line each surviving line came from.
|
|
1537
1867
|
*/
|
|
1538
1868
|
function stampSourceRanges(
|
|
1539
|
-
blocks:
|
|
1869
|
+
blocks: BlockNodeWithMath[],
|
|
1540
1870
|
blockStartLines: number[],
|
|
1541
1871
|
blockEndLines: (number | undefined)[],
|
|
1542
1872
|
lines: string[],
|
|
@@ -1581,10 +1911,17 @@ function stampSourceRanges(
|
|
|
1581
1911
|
// Incremental parsing
|
|
1582
1912
|
// ---------------------------------------------------------------------------
|
|
1583
1913
|
|
|
1584
|
-
|
|
1914
|
+
type IncrementalBlockNode<MathEnabled extends boolean> =
|
|
1915
|
+
MathEnabled extends true ? BlockNodeWithMath : BlockNode;
|
|
1916
|
+
|
|
1917
|
+
declare const incrementalStateMode: unique symbol;
|
|
1918
|
+
|
|
1919
|
+
export interface IncrementalState<MathEnabled extends boolean = false> {
|
|
1920
|
+
/** @internal Nominally couples a factory-created cache to its node union. */
|
|
1921
|
+
readonly [incrementalStateMode]: MathEnabled;
|
|
1585
1922
|
prevInput: string;
|
|
1586
1923
|
settledText: string;
|
|
1587
|
-
settledBlocks:
|
|
1924
|
+
settledBlocks: IncrementalBlockNode<MathEnabled>[];
|
|
1588
1925
|
settledUpTo: number;
|
|
1589
1926
|
/**
|
|
1590
1927
|
* The `autolink` option the cached `settledBlocks` were parsed with.
|
|
@@ -1593,6 +1930,8 @@ export interface IncrementalState {
|
|
|
1593
1930
|
* with newly-arriving content.
|
|
1594
1931
|
*/
|
|
1595
1932
|
autolink?: 'gfm';
|
|
1933
|
+
/** Whether the cached settled blocks were parsed with math enabled. */
|
|
1934
|
+
math?: MathEnabled;
|
|
1596
1935
|
/**
|
|
1597
1936
|
* The `sourceRanges` option the cached `settledBlocks` were parsed with.
|
|
1598
1937
|
* Flipping it invalidates them the same way `autolink` does: they either
|
|
@@ -1632,10 +1971,15 @@ type IncrementalCache = {
|
|
|
1632
1971
|
work: IncrementalWork;
|
|
1633
1972
|
};
|
|
1634
1973
|
|
|
1635
|
-
const incrementalCaches = new WeakMap<
|
|
1974
|
+
const incrementalCaches = new WeakMap<
|
|
1975
|
+
IncrementalState<boolean>,
|
|
1976
|
+
IncrementalCache
|
|
1977
|
+
>();
|
|
1636
1978
|
|
|
1637
|
-
function makeIncrementalCache(
|
|
1638
|
-
|
|
1979
|
+
function makeIncrementalCache(
|
|
1980
|
+
state: IncrementalState<boolean>,
|
|
1981
|
+
): IncrementalCache {
|
|
1982
|
+
const {defs} = extractLinkDefinitions(state.settledText, state.math);
|
|
1639
1983
|
const cache: IncrementalCache = {
|
|
1640
1984
|
settledEnd: state.settledText.length,
|
|
1641
1985
|
settledLinkDefs: new Map(defs),
|
|
@@ -1653,13 +1997,19 @@ function makeIncrementalCache(state: IncrementalState): IncrementalCache {
|
|
|
1653
1997
|
return cache;
|
|
1654
1998
|
}
|
|
1655
1999
|
|
|
1656
|
-
|
|
1657
|
-
|
|
2000
|
+
/**
|
|
2001
|
+
* Create an incremental parser cache. Use the `<true>` type argument with
|
|
2002
|
+
* `MathParseOptions` so the cache and returned nodes share the math contract.
|
|
2003
|
+
*/
|
|
2004
|
+
export function createIncrementalState<
|
|
2005
|
+
MathEnabled extends boolean = false,
|
|
2006
|
+
>(): IncrementalState<MathEnabled> {
|
|
2007
|
+
const state = {
|
|
1658
2008
|
prevInput: '',
|
|
1659
2009
|
settledText: '',
|
|
1660
2010
|
settledBlocks: [],
|
|
1661
2011
|
settledUpTo: 0,
|
|
1662
|
-
}
|
|
2012
|
+
} as unknown as IncrementalState<MathEnabled>;
|
|
1663
2013
|
makeIncrementalCache(state);
|
|
1664
2014
|
return state;
|
|
1665
2015
|
}
|
|
@@ -1669,7 +2019,7 @@ export function createIncrementalState(): IncrementalState {
|
|
|
1669
2019
|
* @internal Exported from this module for performance regression tests only.
|
|
1670
2020
|
*/
|
|
1671
2021
|
export function getIncrementalParseWork(
|
|
1672
|
-
state: IncrementalState
|
|
2022
|
+
state: IncrementalState<boolean>,
|
|
1673
2023
|
): IncrementalWork {
|
|
1674
2024
|
return (
|
|
1675
2025
|
incrementalCaches.get(state)?.work ?? {
|
|
@@ -1695,47 +2045,91 @@ export function getIncrementalParseWork(
|
|
|
1695
2045
|
* the boundary to -1 even though the content before the fence opened cannot be
|
|
1696
2046
|
* changed by anything typed inside it.
|
|
1697
2047
|
*/
|
|
1698
|
-
function findSettledBoundary(
|
|
2048
|
+
function findSettledBoundary(
|
|
2049
|
+
lines: string[],
|
|
2050
|
+
math = false,
|
|
2051
|
+
): {
|
|
1699
2052
|
boundary: number;
|
|
1700
2053
|
openFence: boolean;
|
|
2054
|
+
openMath: boolean;
|
|
1701
2055
|
} {
|
|
1702
2056
|
let inFence = false;
|
|
1703
2057
|
let fenceMarker = '';
|
|
2058
|
+
let mathContainer: DisplayMathContainer | null = null;
|
|
2059
|
+
let suppressMathUntilBoundary = false;
|
|
1704
2060
|
let lastBoundary = -1;
|
|
1705
2061
|
let boundaryBeforeFence = -1;
|
|
2062
|
+
let boundaryBeforeMath = -1;
|
|
1706
2063
|
|
|
1707
2064
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
1708
2065
|
const line = lines[lineIndex];
|
|
1709
2066
|
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
inFence = true;
|
|
1715
|
-
fenceMarker = fenceMatch[1];
|
|
1716
|
-
boundaryBeforeFence = lastBoundary;
|
|
1717
|
-
} else if (
|
|
2067
|
+
if (inFence) {
|
|
2068
|
+
const fenceMatch = line.match(/^(`{3,}|~{3,})/);
|
|
2069
|
+
if (
|
|
2070
|
+
fenceMatch &&
|
|
1718
2071
|
fenceMatch[1].startsWith(fenceMarker[0]) &&
|
|
1719
2072
|
fenceMatch[1].length >= fenceMarker.length
|
|
1720
2073
|
) {
|
|
1721
2074
|
inFence = false;
|
|
1722
2075
|
fenceMarker = '';
|
|
1723
2076
|
}
|
|
2077
|
+
continue;
|
|
1724
2078
|
}
|
|
1725
2079
|
|
|
1726
|
-
if (
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
2080
|
+
if (mathContainer != null) {
|
|
2081
|
+
const state = displayMathLineState(line, mathContainer);
|
|
2082
|
+
if (state === 'close') {
|
|
2083
|
+
mathContainer = null;
|
|
2084
|
+
continue;
|
|
2085
|
+
}
|
|
2086
|
+
if (state === 'inside') {
|
|
2087
|
+
continue;
|
|
2088
|
+
}
|
|
2089
|
+
// The list item or blockquote ended before a closer arrived. The parser
|
|
2090
|
+
// treats that unmatched opener literally, so resume ordinary boundary
|
|
2091
|
+
// detection on this first line outside the container.
|
|
2092
|
+
mathContainer = null;
|
|
2093
|
+
suppressMathUntilBoundary = true;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// A complete same-line `$$…$$` expression never changes boundary state.
|
|
2097
|
+
// A standalone marker may belong to the top level, a blockquote, or one
|
|
2098
|
+
// list item; remember that container so its continuation marker closes the
|
|
2099
|
+
// same expression instead of opening a new one.
|
|
2100
|
+
if (math && !suppressMathUntilBoundary) {
|
|
2101
|
+
const container = displayMathContainer(line);
|
|
2102
|
+
if (container != null) {
|
|
2103
|
+
mathContainer = container;
|
|
2104
|
+
boundaryBeforeMath = lastBoundary;
|
|
2105
|
+
continue;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
const fenceMatch = line.match(/^(`{3,}|~{3,})/);
|
|
2110
|
+
if (fenceMatch) {
|
|
2111
|
+
inFence = true;
|
|
2112
|
+
fenceMarker = fenceMatch[1];
|
|
2113
|
+
boundaryBeforeFence = lastBoundary;
|
|
2114
|
+
continue;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
if (line.trim() === '') {
|
|
2118
|
+
suppressMathUntilBoundary = false;
|
|
2119
|
+
if (lineIndex > 0 && lineIndex < lines.length - 1) {
|
|
2120
|
+
lastBoundary = lineIndex;
|
|
2121
|
+
}
|
|
1733
2122
|
}
|
|
1734
2123
|
}
|
|
1735
2124
|
|
|
1736
2125
|
return {
|
|
1737
|
-
boundary: inFence
|
|
2126
|
+
boundary: inFence
|
|
2127
|
+
? boundaryBeforeFence
|
|
2128
|
+
: mathContainer != null
|
|
2129
|
+
? boundaryBeforeMath
|
|
2130
|
+
: lastBoundary,
|
|
1738
2131
|
openFence: inFence,
|
|
2132
|
+
openMath: mathContainer != null,
|
|
1739
2133
|
};
|
|
1740
2134
|
}
|
|
1741
2135
|
|
|
@@ -1743,10 +2137,55 @@ function findSettledBoundary(lines: string[]): {
|
|
|
1743
2137
|
* Strip trailing incomplete inline syntax that appears during streaming.
|
|
1744
2138
|
* Only affects the tail of the last line — safe to apply to the full string.
|
|
1745
2139
|
*/
|
|
1746
|
-
export function trimStreamingArtifacts(
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
2140
|
+
export function trimStreamingArtifacts(
|
|
2141
|
+
input: string,
|
|
2142
|
+
options?: {math?: boolean},
|
|
2143
|
+
): string {
|
|
2144
|
+
// First remove an incomplete display expression as one structural unit. This
|
|
2145
|
+
// full-input scan distinguishes a terminal nested closer from a new opener;
|
|
2146
|
+
// looking only at the final `$$` line cannot.
|
|
2147
|
+
const displayTrimmed = options?.math ? trimOpenDisplayMath(input) : input;
|
|
2148
|
+
const lastNL = displayTrimmed.lastIndexOf('\n');
|
|
2149
|
+
const prefix = lastNL === -1 ? '' : displayTrimmed.slice(0, lastNL + 1);
|
|
2150
|
+
let tail = lastNL === -1 ? displayTrimmed : displayTrimmed.slice(lastNL + 1);
|
|
2151
|
+
|
|
2152
|
+
if (options?.math) {
|
|
2153
|
+
// Hold an unmatched inline opener so raw TeX syntax does not flash while
|
|
2154
|
+
// streaming. If another unescaped dollar is already present but fails the
|
|
2155
|
+
// closing-boundary rule, keep both literal (the currency case).
|
|
2156
|
+
for (let index = 0; index < tail.length; index++) {
|
|
2157
|
+
if (
|
|
2158
|
+
tail[index] === '$' &&
|
|
2159
|
+
tail[index + 1] == null &&
|
|
2160
|
+
tail[index - 1] !== '$' &&
|
|
2161
|
+
!/\d/.test(tail[index - 1] ?? '') &&
|
|
2162
|
+
!isEscaped(tail, index)
|
|
2163
|
+
) {
|
|
2164
|
+
tail = tail.slice(0, index);
|
|
2165
|
+
break;
|
|
2166
|
+
}
|
|
2167
|
+
if (!isInlineMathStart(tail, index)) {
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2170
|
+
const close = findInlineMathEnd(tail, index);
|
|
2171
|
+
if (close !== -1) {
|
|
2172
|
+
index = close;
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
let laterDollar = -1;
|
|
2176
|
+
for (let next = index + 1; next < tail.length; next++) {
|
|
2177
|
+
if (tail[next] === '$' && !isEscaped(tail, next)) {
|
|
2178
|
+
laterDollar = next;
|
|
2179
|
+
break;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
if (laterDollar === -1) {
|
|
2183
|
+
tail = tail.slice(0, index);
|
|
2184
|
+
break;
|
|
2185
|
+
}
|
|
2186
|
+
index = laterDollar;
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
1750
2189
|
|
|
1751
2190
|
// Scan backwards for unclosed syntax markers — no regex to avoid ReDoS
|
|
1752
2191
|
// Find the last unclosed [ or ![ (link/image start)
|
|
@@ -1904,6 +2343,67 @@ export function trimStreamingArtifacts(input: string): string {
|
|
|
1904
2343
|
* Once a table is established (header + separator exist), new data rows
|
|
1905
2344
|
* render immediately — no suppression.
|
|
1906
2345
|
*/
|
|
2346
|
+
function trimOpenDisplayMath(text: string): string {
|
|
2347
|
+
const lines = text.split('\n');
|
|
2348
|
+
let inFence = false;
|
|
2349
|
+
let fenceMarker = '';
|
|
2350
|
+
let mathContainer: DisplayMathContainer | null = null;
|
|
2351
|
+
let suppressMathUntilBoundary = false;
|
|
2352
|
+
let mathStartLine = -1;
|
|
2353
|
+
|
|
2354
|
+
for (let index = 0; index < lines.length; index++) {
|
|
2355
|
+
const line = lines[index];
|
|
2356
|
+
if (inFence) {
|
|
2357
|
+
const fence = line.match(/^(`{3,}|~{3,})/);
|
|
2358
|
+
if (
|
|
2359
|
+
fence != null &&
|
|
2360
|
+
fence[1].startsWith(fenceMarker[0]) &&
|
|
2361
|
+
fence[1].length >= fenceMarker.length
|
|
2362
|
+
) {
|
|
2363
|
+
inFence = false;
|
|
2364
|
+
fenceMarker = '';
|
|
2365
|
+
}
|
|
2366
|
+
continue;
|
|
2367
|
+
}
|
|
2368
|
+
if (mathContainer != null) {
|
|
2369
|
+
const state = displayMathLineState(line, mathContainer);
|
|
2370
|
+
if (state === 'close') {
|
|
2371
|
+
mathContainer = null;
|
|
2372
|
+
mathStartLine = -1;
|
|
2373
|
+
continue;
|
|
2374
|
+
}
|
|
2375
|
+
if (state === 'inside') {
|
|
2376
|
+
continue;
|
|
2377
|
+
}
|
|
2378
|
+
mathContainer = null;
|
|
2379
|
+
mathStartLine = -1;
|
|
2380
|
+
suppressMathUntilBoundary = true;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
if (line.trim() === '') {
|
|
2384
|
+
suppressMathUntilBoundary = false;
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
const fence = line.match(/^(`{3,}|~{3,})/);
|
|
2388
|
+
if (fence != null) {
|
|
2389
|
+
inFence = true;
|
|
2390
|
+
fenceMarker = fence[1];
|
|
2391
|
+
continue;
|
|
2392
|
+
}
|
|
2393
|
+
const container = suppressMathUntilBoundary
|
|
2394
|
+
? null
|
|
2395
|
+
: displayMathContainer(line);
|
|
2396
|
+
if (container != null) {
|
|
2397
|
+
mathContainer = container;
|
|
2398
|
+
mathStartLine = index;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
return mathContainer != null && mathStartLine >= 0
|
|
2403
|
+
? lines.slice(0, mathStartLine).join('\n').trimEnd()
|
|
2404
|
+
: text;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
1907
2407
|
function trimUnsettledStructural(text: string): string {
|
|
1908
2408
|
const lines = text.split('\n');
|
|
1909
2409
|
|
|
@@ -1981,9 +2481,9 @@ function atOffset(opts: ResolvedOptions, offset: number): ResolvedOptions {
|
|
|
1981
2481
|
* lists even though the full-text parser joins them per CommonMark §5.3.
|
|
1982
2482
|
*/
|
|
1983
2483
|
function mergeSettledBlocks(
|
|
1984
|
-
prev:
|
|
1985
|
-
delta:
|
|
1986
|
-
):
|
|
2484
|
+
prev: BlockNodeWithMath[],
|
|
2485
|
+
delta: BlockNodeWithMath[],
|
|
2486
|
+
): BlockNodeWithMath[] {
|
|
1987
2487
|
if (prev.length === 0 || delta.length === 0) {
|
|
1988
2488
|
return [...prev, ...delta];
|
|
1989
2489
|
}
|
|
@@ -1995,7 +2495,7 @@ function mergeSettledBlocks(
|
|
|
1995
2495
|
prevLast.ordered === deltaFirst.ordered &&
|
|
1996
2496
|
prevLast.delimiter === deltaFirst.delimiter
|
|
1997
2497
|
) {
|
|
1998
|
-
const merged:
|
|
2498
|
+
const merged: BlockNodeWithMath = {
|
|
1999
2499
|
type: 'list',
|
|
2000
2500
|
ordered: prevLast.ordered,
|
|
2001
2501
|
start: prevLast.start,
|
|
@@ -2014,7 +2514,10 @@ function mergeSettledBlocks(
|
|
|
2014
2514
|
}
|
|
2015
2515
|
|
|
2016
2516
|
/** Append a newly-settled slice without copying the already-settled prefix. */
|
|
2017
|
-
function appendSettledBlocks(
|
|
2517
|
+
function appendSettledBlocks(
|
|
2518
|
+
prev: BlockNodeWithMath[],
|
|
2519
|
+
delta: BlockNodeWithMath[],
|
|
2520
|
+
): void {
|
|
2018
2521
|
if (delta.length === 0) {
|
|
2019
2522
|
return;
|
|
2020
2523
|
}
|
|
@@ -2066,7 +2569,7 @@ function sameUnsettledDefinitions(
|
|
|
2066
2569
|
}
|
|
2067
2570
|
|
|
2068
2571
|
function resetIncrementalCache(
|
|
2069
|
-
state: IncrementalState
|
|
2572
|
+
state: IncrementalState<boolean>,
|
|
2070
2573
|
cache: IncrementalCache,
|
|
2071
2574
|
): void {
|
|
2072
2575
|
state.prevInput = '';
|
|
@@ -2074,6 +2577,7 @@ function resetIncrementalCache(
|
|
|
2074
2577
|
state.settledBlocks = [];
|
|
2075
2578
|
state.settledUpTo = 0;
|
|
2076
2579
|
state.linkDefsKey = undefined;
|
|
2580
|
+
state.math = undefined;
|
|
2077
2581
|
cache.settledEnd = 0;
|
|
2078
2582
|
cache.settledLinkDefs.clear();
|
|
2079
2583
|
cache.tailLinkDefs = new Map();
|
|
@@ -2100,32 +2604,39 @@ function resetIncrementalCache(
|
|
|
2100
2604
|
*/
|
|
2101
2605
|
export function parseMarkdownIncremental(
|
|
2102
2606
|
input: string,
|
|
2103
|
-
state: IncrementalState
|
|
2607
|
+
state: IncrementalState<false>,
|
|
2104
2608
|
sourceIds?: ReadonlySet<string>,
|
|
2105
2609
|
): BlockNode[];
|
|
2106
2610
|
export function parseMarkdownIncremental(
|
|
2107
2611
|
input: string,
|
|
2108
|
-
state: IncrementalState
|
|
2612
|
+
state: IncrementalState<true>,
|
|
2613
|
+
options: MathParseOptions,
|
|
2614
|
+
): BlockNodeWithMath[];
|
|
2615
|
+
export function parseMarkdownIncremental(
|
|
2616
|
+
input: string,
|
|
2617
|
+
state: IncrementalState<false>,
|
|
2109
2618
|
options: ParseOptions,
|
|
2110
2619
|
): BlockNode[];
|
|
2111
2620
|
export function parseMarkdownIncremental(
|
|
2112
2621
|
input: string,
|
|
2113
|
-
state: IncrementalState
|
|
2114
|
-
arg?: ReadonlySet<string> |
|
|
2115
|
-
):
|
|
2622
|
+
state: IncrementalState<boolean>,
|
|
2623
|
+
arg?: ReadonlySet<string> | RuntimeParseOptions,
|
|
2624
|
+
): BlockNodeWithMath[] {
|
|
2116
2625
|
const opts = resolveOptions(arg);
|
|
2117
2626
|
const cache = incrementalCaches.get(state) ?? makeIncrementalCache(state);
|
|
2118
2627
|
let reparseSettled = false;
|
|
2119
2628
|
|
|
2120
|
-
// Invalidate cache when
|
|
2629
|
+
// Invalidate cache when an option that changes parsed nodes flips — cached
|
|
2121
2630
|
// settled blocks were parsed with the previous setting and would otherwise
|
|
2122
2631
|
// be reused unchanged.
|
|
2123
2632
|
if (
|
|
2124
2633
|
state.autolink !== opts.autolink ||
|
|
2634
|
+
state.math !== opts.math ||
|
|
2125
2635
|
Boolean(state.sourceRanges) !== Boolean(opts.sourceRanges)
|
|
2126
2636
|
) {
|
|
2127
2637
|
reparseSettled = true;
|
|
2128
2638
|
state.autolink = opts.autolink;
|
|
2639
|
+
state.math = opts.math;
|
|
2129
2640
|
state.sourceRanges = opts.sourceRanges;
|
|
2130
2641
|
}
|
|
2131
2642
|
if (input === '') {
|
|
@@ -2147,6 +2658,7 @@ export function parseMarkdownIncremental(
|
|
|
2147
2658
|
) {
|
|
2148
2659
|
resetIncrementalCache(state, cache);
|
|
2149
2660
|
state.autolink = opts.autolink;
|
|
2661
|
+
state.math = opts.math;
|
|
2150
2662
|
state.sourceRanges = opts.sourceRanges;
|
|
2151
2663
|
}
|
|
2152
2664
|
|
|
@@ -2156,7 +2668,10 @@ export function parseMarkdownIncremental(
|
|
|
2156
2668
|
const oldSettledEnd = cache.settledEnd;
|
|
2157
2669
|
const tailRaw = input.slice(oldSettledEnd);
|
|
2158
2670
|
const tailLines = tailRaw.split('\n');
|
|
2159
|
-
const {boundary, openFence} = findSettledBoundary(
|
|
2671
|
+
const {boundary, openFence, openMath} = findSettledBoundary(
|
|
2672
|
+
tailLines,
|
|
2673
|
+
opts.math,
|
|
2674
|
+
);
|
|
2160
2675
|
const settledDelta =
|
|
2161
2676
|
boundary >= 0 ? tailLines.slice(0, boundary).join('\n') : '';
|
|
2162
2677
|
const nextSettledEnd = oldSettledEnd + settledDelta.length;
|
|
@@ -2168,7 +2683,7 @@ export function parseMarkdownIncremental(
|
|
|
2168
2683
|
// blocks: document-global references may precede their footer definition.
|
|
2169
2684
|
let definitionsChanged = false;
|
|
2170
2685
|
if (settledDelta !== '') {
|
|
2171
|
-
const {defs: deltaDefs} = extractLinkDefinitions(settledDelta);
|
|
2686
|
+
const {defs: deltaDefs} = extractLinkDefinitions(settledDelta, opts.math);
|
|
2172
2687
|
for (const [label, destination] of deltaDefs) {
|
|
2173
2688
|
if (!cache.settledLinkDefs.has(label)) {
|
|
2174
2689
|
cache.settledLinkDefs.set(label, destination);
|
|
@@ -2178,7 +2693,10 @@ export function parseMarkdownIncremental(
|
|
|
2178
2693
|
}
|
|
2179
2694
|
}
|
|
2180
2695
|
}
|
|
2181
|
-
const {defs: tailLinkDefs} = extractLinkDefinitions(
|
|
2696
|
+
const {defs: tailLinkDefs} = extractLinkDefinitions(
|
|
2697
|
+
unsettledInput,
|
|
2698
|
+
opts.math,
|
|
2699
|
+
);
|
|
2182
2700
|
if (
|
|
2183
2701
|
!sameUnsettledDefinitions(
|
|
2184
2702
|
cache.tailLinkDefs,
|
|
@@ -2209,13 +2727,22 @@ export function parseMarkdownIncremental(
|
|
|
2209
2727
|
cache.settledEnd = nextSettledEnd;
|
|
2210
2728
|
}
|
|
2211
2729
|
|
|
2212
|
-
const
|
|
2730
|
+
const trimmedUnsettledInput = unsettledInput.trim();
|
|
2731
|
+
// String#trim removes the CR that belongs to the final content line of a
|
|
2732
|
+
// CRLF snapshot along with trailing blank lines. Keep that one byte so
|
|
2733
|
+
// source ranges and delimiter content remain identical to a full parse.
|
|
2734
|
+
const unsettledRaw =
|
|
2735
|
+
trimmedUnsettledInput !== '' && /\r(?:\n[\s]*)?$/.test(unsettledInput)
|
|
2736
|
+
? `${trimmedUnsettledInput}\r`
|
|
2737
|
+
: trimmedUnsettledInput;
|
|
2213
2738
|
// Structural trimming holds back lines that look like an incomplete list or
|
|
2214
2739
|
// table, which inside a fence is ordinary code: a TypeScript union or a `- `
|
|
2215
2740
|
// would disappear from the code block as it streams.
|
|
2216
2741
|
const unsettledText = openFence
|
|
2217
2742
|
? unsettledRaw
|
|
2218
|
-
:
|
|
2743
|
+
: openMath
|
|
2744
|
+
? trimOpenDisplayMath(unsettledRaw)
|
|
2745
|
+
: trimUnsettledStructural(unsettledRaw);
|
|
2219
2746
|
|
|
2220
2747
|
let parsedSettledBlocks = 0;
|
|
2221
2748
|
if (reparseSettled) {
|
|
@@ -2275,13 +2802,14 @@ export function parseMarkdownIncremental(
|
|
|
2275
2802
|
// resolve to a rendered heading by construction.
|
|
2276
2803
|
|
|
2277
2804
|
/** Flatten inline nodes into their plain text content. */
|
|
2278
|
-
export function inlineText(nodes:
|
|
2805
|
+
export function inlineText(nodes: ReadonlyArray<InlineNodeWithMath>): string {
|
|
2279
2806
|
return nodes
|
|
2280
2807
|
.map(node => {
|
|
2281
2808
|
switch (node.type) {
|
|
2282
2809
|
case 'text':
|
|
2283
2810
|
case 'code':
|
|
2284
|
-
|
|
2811
|
+
case 'math':
|
|
2812
|
+
return node.type === 'math' ? node.value : node.content;
|
|
2285
2813
|
case 'bold':
|
|
2286
2814
|
case 'italic':
|
|
2287
2815
|
case 'strikethrough':
|