@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/DateRangeInput/DateRangeInput.d.ts +3 -0
  3. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  4. package/dist/DateRangeInput/DateRangeInput.js +16 -9
  5. package/dist/Dialog/DialogHeader.d.ts +1 -1
  6. package/dist/Dialog/DialogHeader.d.ts.map +1 -1
  7. package/dist/Dialog/DialogHeader.js +10 -7
  8. package/dist/FileInput/FileInput.d.ts.map +1 -1
  9. package/dist/FileInput/FileInput.js +9 -3
  10. package/dist/Markdown/Markdown.d.ts +10 -2
  11. package/dist/Markdown/Markdown.d.ts.map +1 -1
  12. package/dist/Markdown/Markdown.js +58 -14
  13. package/dist/Markdown/index.d.ts +1 -1
  14. package/dist/Markdown/index.d.ts.map +1 -1
  15. package/dist/Markdown/parser.d.ts +126 -12
  16. package/dist/Markdown/parser.d.ts.map +1 -1
  17. package/dist/Markdown/parser.js +369 -34
  18. package/dist/Markdown/utils.d.ts +1 -1
  19. package/dist/Markdown/utils.d.ts.map +1 -1
  20. package/dist/Slider/Slider.d.ts.map +1 -1
  21. package/dist/Slider/Slider.js +5 -2
  22. package/dist/Spinner/Spinner.d.ts +1 -1
  23. package/dist/Spinner/Spinner.d.ts.map +1 -1
  24. package/dist/Spinner/Spinner.js +23 -15
  25. package/dist/astryx.css +2 -1
  26. package/locales/en.json +16 -0
  27. package/locales/pseudo.json +12 -0
  28. package/package.json +6 -4
  29. package/scripts/agent-doc-state.mjs +1 -1
  30. package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
  31. package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
  32. package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
  33. package/src/DateRangeInput/DateRangeInput.tsx +29 -20
  34. package/src/Dialog/Dialog.doc.mjs +3 -0
  35. package/src/Dialog/Dialog.spec.md +1 -1
  36. package/src/Dialog/DialogHeader.doc.mjs +38 -0
  37. package/src/Dialog/DialogHeader.test.tsx +49 -0
  38. package/src/Dialog/DialogHeader.tsx +23 -4
  39. package/src/Dialog/modules/DialogHeader.spec.md +152 -0
  40. package/src/FileInput/FileInput.doc.mjs +2 -0
  41. package/src/FileInput/FileInput.spec.md +199 -0
  42. package/src/FileInput/FileInput.test.tsx +14 -0
  43. package/src/FileInput/FileInput.tsx +13 -3
  44. package/src/Markdown/Markdown.doc.mjs +167 -42
  45. package/src/Markdown/Markdown.public.test.ts +157 -0
  46. package/src/Markdown/Markdown.spec.md +149 -70
  47. package/src/Markdown/Markdown.test.tsx +107 -3
  48. package/src/Markdown/Markdown.tsx +116 -35
  49. package/src/Markdown/incremental.test.ts +175 -7
  50. package/src/Markdown/index.ts +6 -0
  51. package/src/Markdown/parser.perf.test.ts +3 -1
  52. package/src/Markdown/parser.test.ts +122 -0
  53. package/src/Markdown/parser.ts +609 -81
  54. package/src/Markdown/utils.ts +6 -0
  55. package/src/Slider/Slider.doc.mjs +16 -0
  56. package/src/Slider/Slider.spec.md +61 -47
  57. package/src/Slider/Slider.test.tsx +18 -0
  58. package/src/Slider/Slider.tsx +12 -6
  59. package/src/Spinner/Spinner.doc.mjs +6 -3
  60. package/src/Spinner/Spinner.test.tsx +37 -0
  61. package/src/Spinner/Spinner.tsx +31 -14
  62. package/src/theme/derivedVarRegistry.test.ts +6 -4
@@ -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 helpers
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
- export type BlockNode = BlockNodeKind & {
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 BlockNodeKind =
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
- export type ParseOptions = {
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> | ParseOptions | undefined,
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 ParseOptions;
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(input: string): {
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: InlineNode; end: number} | null {
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: InlineNode; end: number} | null {
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> | ParseOptions,
501
- ): InlineNode[] {
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(text: string, opts: ResolvedOptions): InlineNode[] {
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(text: string, opts: ResolvedOptions): InlineNode[] {
520
- const nodes: InlineNode[] = [];
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 (end < text.length && !'*_~`[!\\\n\u3010'.includes(text[end])) {
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): InlineNode[] {
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: InlineNode[] = [];
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: InlineNode[]): InlineNode[] {
1027
- const out: InlineNode[] = [];
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: BlockNode; nextIndex: number} {
1202
- const headers: TableCellNode[] = splitTableRow(lines[lineIndex]).map(
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: TableCellNode[][] = [];
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: BlockNode; nextIndex: number} {
1245
- const items: ListItemNode[] = [];
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> | ParseOptions,
1345
- ): BlockNode[] {
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
- ): BlockNode[] {
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: BlockNode[] = [];
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: BlockNode, endLine?: number) => {
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: BlockNode[],
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
- export interface IncrementalState {
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: BlockNode[];
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<IncrementalState, IncrementalCache>();
1974
+ const incrementalCaches = new WeakMap<
1975
+ IncrementalState<boolean>,
1976
+ IncrementalCache
1977
+ >();
1636
1978
 
1637
- function makeIncrementalCache(state: IncrementalState): IncrementalCache {
1638
- const {defs} = extractLinkDefinitions(state.settledText);
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
- export function createIncrementalState(): IncrementalState {
1657
- const state: IncrementalState = {
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(lines: string[]): {
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
- // Fence open / close — match the specific marker character and length
1711
- const fenceMatch = line.match(/^(`{3,}|~{3,})/);
1712
- if (fenceMatch) {
1713
- if (!inFence) {
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
- !inFence &&
1728
- line.trim() === '' &&
1729
- lineIndex > 0 &&
1730
- lineIndex < lines.length - 1
1731
- ) {
1732
- lastBoundary = lineIndex;
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 ? boundaryBeforeFence : lastBoundary,
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(input: string): string {
1747
- const lastNL = input.lastIndexOf('\n');
1748
- const prefix = lastNL === -1 ? '' : input.slice(0, lastNL + 1);
1749
- let tail = lastNL === -1 ? input : input.slice(lastNL + 1);
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: BlockNode[],
1985
- delta: BlockNode[],
1986
- ): BlockNode[] {
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: BlockNode = {
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(prev: BlockNode[], delta: BlockNode[]): void {
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> | ParseOptions,
2115
- ): BlockNode[] {
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 the autolink or sourceRanges option flips — cached
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(tailLines);
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(unsettledInput);
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 unsettledRaw = unsettledInput.trim();
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
- : trimUnsettledStructural(unsettledRaw);
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: InlineNode[]): string {
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
- return node.content;
2811
+ case 'math':
2812
+ return node.type === 'math' ? node.value : node.content;
2285
2813
  case 'bold':
2286
2814
  case 'italic':
2287
2815
  case 'strikethrough':