@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
@@ -4,8 +4,8 @@
4
4
 
5
5
  /**
6
6
  * @file Markdown.tsx
7
- * @input Markdown string, parser AST types
8
- * @output Exports Markdown component and MarkdownProps
7
+ * @input Markdown string, parser AST types, optional custom renderers
8
+ * @output Exports Markdown component, MarkdownProps, and renderer contracts
9
9
  * @position Core implementation; renders markdown as Astryx components
10
10
  */
11
11
 
@@ -57,7 +57,13 @@ import {
57
57
  slugify,
58
58
  uniqueSlug,
59
59
  } from './parser';
60
- import type {BlockNode, InlineNode, IncrementalState} from './parser';
60
+ import type {
61
+ BlockNodeWithMath,
62
+ InlineNodeWithMath,
63
+ IncrementalState,
64
+ MathParseOptions,
65
+ ParseOptions,
66
+ } from './parser';
61
67
  import {themeProps} from '../utils/themeProps';
62
68
  import {useTranslator, type TranslatorFn} from '../i18n';
63
69
 
@@ -70,7 +76,7 @@ type SyncReactNode = Exclude<React.ReactNode, Promise<unknown>>;
70
76
  /**
71
77
  * A plugin that transforms text patterns into custom React elements
72
78
  * inside Markdown. Applied to parsed text nodes only — code blocks,
73
- * inline code, and other non-prose contexts are unaffected.
79
+ * inline code, math, and other non-prose contexts are unaffected.
74
80
  *
75
81
  * Follows Lexical's TextMatchTransformer architecture:
76
82
  * - `pattern` for initial regex matching
@@ -102,6 +108,14 @@ export type MarkdownSource = CitationSource;
102
108
  export interface MarkdownComponents {
103
109
  code?: React.ComponentType<{code: string; language?: string}>;
104
110
  inlineCode?: React.ComponentType<{children: string}>;
111
+ /**
112
+ * Renders opt-in `$…$` and `$$…$$` math. Supplying this renderer enables
113
+ * math parsing; Astryx passes the raw expression and does not execute it.
114
+ */
115
+ math?: React.ComponentType<{
116
+ value: string;
117
+ display: 'inline' | 'block';
118
+ }>;
105
119
  citation?: React.ComponentType<{
106
120
  source: CitationSource;
107
121
  number: number;
@@ -183,7 +197,7 @@ export interface MarkdownProps extends BaseProps<HTMLElement> {
183
197
  components?: Partial<MarkdownComponents>;
184
198
  /**
185
199
  * Plugins that transform text patterns into custom React elements.
186
- * Applied to text nodes after parsing — code blocks and inline code
200
+ * Applied to text nodes after parsing — code blocks, inline code, and math
187
201
  * are unaffected. Patterns are matched in order; first match wins
188
202
  * for overlapping ranges.
189
203
  */
@@ -473,7 +487,7 @@ interface StreamingCursor {
473
487
  * Count the total text characters in inline nodes without rendering.
474
488
  * Used to advance the cursor past a block that will be faded as a whole unit.
475
489
  */
476
- function countInlineTextLength(nodes: InlineNode[]): number {
490
+ function countInlineTextLength(nodes: InlineNodeWithMath[]): number {
477
491
  let len = 0;
478
492
  for (const node of nodes) {
479
493
  switch (node.type) {
@@ -483,6 +497,9 @@ function countInlineTextLength(nodes: InlineNode[]): number {
483
497
  case 'code':
484
498
  len += node.content.length;
485
499
  break;
500
+ case 'math':
501
+ len += node.value.length;
502
+ break;
486
503
  case 'image':
487
504
  len += node.alt.length;
488
505
  break;
@@ -506,7 +523,7 @@ function countInlineTextLength(nodes: InlineNode[]): number {
506
523
  /**
507
524
  * Count total text characters in a block node tree.
508
525
  */
509
- function countBlockTextLength(nodes: BlockNode[]): number {
526
+ function countBlockTextLength(nodes: BlockNodeWithMath[]): number {
510
527
  let len = 0;
511
528
  for (const node of nodes) {
512
529
  switch (node.type) {
@@ -517,6 +534,9 @@ function countBlockTextLength(nodes: BlockNode[]): number {
517
534
  case 'codeblock':
518
535
  len += node.content.length;
519
536
  break;
537
+ case 'math':
538
+ len += node.value.length;
539
+ break;
520
540
  case 'blockquote':
521
541
  len += countBlockTextLength(node.children);
522
542
  break;
@@ -748,7 +768,7 @@ function getCitationNumber(ctx: CitationContext, sourceId: string): number {
748
768
  }
749
769
 
750
770
  function renderInline(
751
- node: InlineNode,
771
+ node: InlineNodeWithMath,
752
772
  index: number,
753
773
  onLinkClick: MarkdownProps['onLinkClick'] | undefined,
754
774
  cursor: StreamingCursor,
@@ -847,6 +867,14 @@ function renderInline(
847
867
  );
848
868
  return codeEl;
849
869
  }
870
+ case 'math': {
871
+ const MathComp = components?.math;
872
+ if (MathComp == null) {
873
+ return wrapTextWithFade(`$${node.value}$`, cursor, index);
874
+ }
875
+ cursor.offset += node.value.length;
876
+ return <MathComp key={index} value={node.value} display="inline" />;
877
+ }
850
878
  case 'link': {
851
879
  const safeHref = sanitizeUrl(node.href);
852
880
  if (safeHref == null) {
@@ -984,7 +1012,7 @@ function renderInline(
984
1012
  // ---------------------------------------------------------------------------
985
1013
 
986
1014
  function getElementSpacing(
987
- node: BlockNode,
1015
+ node: BlockNodeWithMath,
988
1016
  density: 'default' | 'compact',
989
1017
  ): StyleXStyles {
990
1018
  const compact = density === 'compact';
@@ -1002,6 +1030,7 @@ function getElementSpacing(
1002
1030
  ? styles.spacingParagraphCompact
1003
1031
  : styles.spacingParagraphDefault;
1004
1032
  case 'codeblock':
1033
+ case 'math':
1005
1034
  return compact
1006
1035
  ? styles.spacingCodeblockCompact
1007
1036
  : styles.spacingCodeblockDefault;
@@ -1029,8 +1058,8 @@ function getElementSpacing(
1029
1058
  * Buckets: ≤6 chars → 60px, 7–15 → 80px, >15 → 120px.
1030
1059
  */
1031
1060
  function computeTableColumnMinWidths(node: {
1032
- headers: {children: InlineNode[]}[];
1033
- rows: {children: InlineNode[]}[][];
1061
+ headers: {children: InlineNodeWithMath[]}[];
1062
+ rows: {children: InlineNodeWithMath[]}[][];
1034
1063
  }): number[] {
1035
1064
  return node.headers.map((h, colIdx) => {
1036
1065
  let maxLen = countInlineTextLength(h.children);
@@ -1047,7 +1076,7 @@ function computeTableColumnMinWidths(node: {
1047
1076
  }
1048
1077
 
1049
1078
  function renderBlock(
1050
- node: BlockNode,
1079
+ node: BlockNodeWithMath,
1051
1080
  index: number,
1052
1081
  blockCount: number,
1053
1082
  density: 'default' | 'compact',
@@ -1061,7 +1090,7 @@ function renderBlock(
1061
1090
  inlinePlugins: MarkdownInlinePlugin[] | undefined,
1062
1091
  components: Partial<MarkdownComponents> | undefined,
1063
1092
  t: TranslatorFn,
1064
- headingIdMap?: ReadonlyMap<BlockNode, string>,
1093
+ headingIdMap?: ReadonlyMap<BlockNodeWithMath, string>,
1065
1094
  ): SyncReactNode {
1066
1095
  const blockAlignMargin = BLOCK_ALIGN_MARGIN[contentAlign];
1067
1096
  const blockAlignStyle =
@@ -1213,6 +1242,18 @@ function renderBlock(
1213
1242
  </div>
1214
1243
  );
1215
1244
  }
1245
+ case 'math': {
1246
+ cursor.offset += node.value.length;
1247
+ const MathComp = components?.math;
1248
+ if (MathComp == null) {
1249
+ return (
1250
+ <div key={index} role="paragraph">
1251
+ {`$$${node.value}$$`}
1252
+ </div>
1253
+ );
1254
+ }
1255
+ return <MathComp key={index} value={node.value} display="block" />;
1256
+ }
1216
1257
  case 'blockquote': {
1217
1258
  const BlockquoteComp = components?.blockquote;
1218
1259
  if (BlockquoteComp) {
@@ -1637,24 +1678,34 @@ export function Markdown({
1637
1678
  [sources],
1638
1679
  );
1639
1680
 
1640
- const parseOptions = useMemo(
1681
+ const hasMathRenderer = components?.math != null;
1682
+ const legacyParseOptions = useMemo<ParseOptions>(
1641
1683
  () => ({sourceIds, autolink}),
1642
1684
  [sourceIds, autolink],
1643
1685
  );
1686
+ const mathParseOptions = useMemo<MathParseOptions>(
1687
+ () => ({sourceIds, autolink, math: true}),
1688
+ [sourceIds, autolink],
1689
+ );
1644
1690
 
1645
1691
  // Smooth bursty streamed chunks into a steady character-by-character reveal.
1646
1692
  // When not streaming, the hook returns children unchanged (no-op).
1647
1693
  const smoothedText = useStreamingText(children, isStreaming);
1648
1694
 
1649
- const incrementalStateRef = useRef<IncrementalState>(
1650
- createIncrementalState(),
1695
+ const incrementalStateRef = useRef<IncrementalState<boolean>>(
1696
+ createIncrementalState<boolean>(),
1651
1697
  );
1652
- // Reset incremental cache when the autolink option toggles — cached
1653
- // settled blocks were parsed with the previous setting.
1698
+ // Reset incremental cache when parser-affecting component options toggle —
1699
+ // cached settled blocks were parsed with the previous setting.
1654
1700
  const prevAutolinkRef = useRef(autolink);
1655
- if (prevAutolinkRef.current !== autolink) {
1656
- incrementalStateRef.current = createIncrementalState();
1701
+ const prevMathRef = useRef(hasMathRenderer);
1702
+ if (
1703
+ prevAutolinkRef.current !== autolink ||
1704
+ prevMathRef.current !== hasMathRenderer
1705
+ ) {
1706
+ incrementalStateRef.current = createIncrementalState<boolean>();
1657
1707
  prevAutolinkRef.current = autolink;
1708
+ prevMathRef.current = hasMathRenderer;
1658
1709
  }
1659
1710
 
1660
1711
  const blocks = useMemo(() => {
@@ -1663,18 +1714,35 @@ export function Markdown({
1663
1714
  }
1664
1715
  if (isStreaming) {
1665
1716
  if (smoothedText === '') {
1666
- incrementalStateRef.current = createIncrementalState();
1717
+ incrementalStateRef.current = createIncrementalState<boolean>();
1667
1718
  return [];
1668
1719
  }
1669
- const input = trimStreamingArtifacts(smoothedText);
1670
- return parseMarkdownIncremental(
1671
- input,
1672
- incrementalStateRef.current,
1673
- parseOptions,
1674
- );
1720
+ const options = hasMathRenderer ? mathParseOptions : legacyParseOptions;
1721
+ const input = trimStreamingArtifacts(smoothedText, options);
1722
+ return hasMathRenderer
1723
+ ? parseMarkdownIncremental(
1724
+ input,
1725
+ incrementalStateRef.current as IncrementalState<true>,
1726
+ mathParseOptions,
1727
+ )
1728
+ : parseMarkdownIncremental(
1729
+ input,
1730
+ incrementalStateRef.current as IncrementalState<false>,
1731
+ legacyParseOptions,
1732
+ );
1675
1733
  }
1676
- return parseMarkdown(children, parseOptions);
1677
- }, [display, smoothedText, children, isStreaming, parseOptions]);
1734
+ return hasMathRenderer
1735
+ ? parseMarkdown(children, mathParseOptions)
1736
+ : parseMarkdown(children, legacyParseOptions);
1737
+ }, [
1738
+ display,
1739
+ smoothedText,
1740
+ children,
1741
+ isStreaming,
1742
+ hasMathRenderer,
1743
+ mathParseOptions,
1744
+ legacyParseOptions,
1745
+ ]);
1678
1746
 
1679
1747
  // Assign each top-level heading the slug that parseOutlineFromMarkdown
1680
1748
  // would derive for it, so Outline hash links built from the same source
@@ -1686,7 +1754,7 @@ export function Markdown({
1686
1754
  if (display === 'inline' || blocks.length === 0) {
1687
1755
  return undefined;
1688
1756
  }
1689
- const map = new Map<BlockNode, string>();
1757
+ const map = new Map<BlockNodeWithMath, string>();
1690
1758
  const counts = new Map<string, number>();
1691
1759
  for (const block of blocks) {
1692
1760
  if (block.type === 'heading') {
@@ -1701,9 +1769,22 @@ export function Markdown({
1701
1769
  if (display !== 'inline') {
1702
1770
  return [];
1703
1771
  }
1704
- const input = isStreaming ? trimStreamingArtifacts(smoothedText) : children;
1705
- return parseInline(input, parseOptions);
1706
- }, [display, smoothedText, children, isStreaming, parseOptions]);
1772
+ const options = hasMathRenderer ? mathParseOptions : legacyParseOptions;
1773
+ const input = isStreaming
1774
+ ? trimStreamingArtifacts(smoothedText, options)
1775
+ : children;
1776
+ return hasMathRenderer
1777
+ ? parseInline(input, mathParseOptions)
1778
+ : parseInline(input, legacyParseOptions);
1779
+ }, [
1780
+ display,
1781
+ smoothedText,
1782
+ children,
1783
+ isStreaming,
1784
+ hasMathRenderer,
1785
+ mathParseOptions,
1786
+ legacyParseOptions,
1787
+ ]);
1707
1788
 
1708
1789
  // Track recent boundaries for stacked fade-in animation.
1709
1790
  // The number of spans needed = ceil(animationDuration / tickInterval).
@@ -1719,8 +1800,8 @@ export function Markdown({
1719
1800
  return Math.min(Math.ceil(duration / tickMs), 12);
1720
1801
  }, [token]);
1721
1802
 
1722
- const prevBlocksRef = useRef<BlockNode[]>([]);
1723
- const prevInlineNodesRef = useRef<InlineNode[]>([]);
1803
+ const prevBlocksRef = useRef<BlockNodeWithMath[]>([]);
1804
+ const prevInlineNodesRef = useRef<InlineNodeWithMath[]>([]);
1724
1805
  const boundariesRef = useRef<number[]>([]);
1725
1806
  const smoothedLen = smoothedText.length;
1726
1807
  const boundaries = useMemo(() => {
@@ -8,7 +8,12 @@ import {
8
8
  getIncrementalParseWork,
9
9
  trimStreamingArtifacts,
10
10
  } from './parser';
11
- import type {BlockNode, InlineNode} from './parser';
11
+ import type {
12
+ BlockNode,
13
+ BlockNodeWithMath,
14
+ InlineNode,
15
+ InlineNodeWithMath,
16
+ } from './parser';
12
17
 
13
18
  function simulateStreaming(fullText: string, chunkSize = 10) {
14
19
  const state = createIncrementalState();
@@ -90,6 +95,150 @@ describe('parseMarkdownIncremental', () => {
90
95
  expect(final).toEqual(full);
91
96
  });
92
97
 
98
+ it('matches the full parse when math delimiters arrive across chunks', () => {
99
+ const text = 'Before $x_1 + *y*$ after.\n\n$$\n\\sum_i x_i\n$$\n\nDone.';
100
+ const state = createIncrementalState<true>();
101
+ let final: BlockNodeWithMath[] = [];
102
+ for (let end = 1; end <= text.length; end++) {
103
+ final = parseMarkdownIncremental(text.slice(0, end), state, {math: true});
104
+ }
105
+ expect(final).toEqual(parseMarkdown(text, {math: true}));
106
+ });
107
+
108
+ describe.each([
109
+ ['list', '- $$\n x + y\n $$'],
110
+ ['task list', '- [ ] $$\n x + y\n $$'],
111
+ ['blockquote', '> $$\n> x + y\n> $$'],
112
+ ['blockquote in a list', '- > $$\n > x + y\n > $$'],
113
+ ['CRLF list', '- $$\r\n x + y\r\n $$\r\n'],
114
+ ] as const)('nested display math in a %s', (_label, text) => {
115
+ it.each([false, true])(
116
+ 'withholds the incomplete container (sourceRanges=%s)',
117
+ sourceRanges => {
118
+ const closingDelimiter = text.lastIndexOf('$$');
119
+ const state = createIncrementalState<true>();
120
+ expect(
121
+ parseMarkdownIncremental(text.slice(0, closingDelimiter), state, {
122
+ math: true,
123
+ sourceRanges,
124
+ }),
125
+ ).toEqual([]);
126
+ },
127
+ );
128
+
129
+ it.each([false, true])(
130
+ 'matches the full parse at every stream split (sourceRanges=%s)',
131
+ sourceRanges => {
132
+ const options = {math: true as const, sourceRanges};
133
+ const full = parseMarkdown(text, options);
134
+
135
+ // One long-lived state sees every character boundary in order.
136
+ const characterState = createIncrementalState<true>();
137
+ let characterResult = parseMarkdownIncremental(
138
+ '',
139
+ characterState,
140
+ options,
141
+ );
142
+ for (let end = 1; end <= text.length; end++) {
143
+ characterResult = parseMarkdownIncremental(
144
+ text.slice(0, end),
145
+ characterState,
146
+ options,
147
+ );
148
+ }
149
+ expect(characterResult).toEqual(full);
150
+
151
+ // Markdown's production streaming path trims partial syntax before it
152
+ // reaches the incremental parser. A terminal nested closer must survive
153
+ // that pass rather than being mistaken for a fresh opener.
154
+ const productionState = createIncrementalState<true>();
155
+ let productionResult = parseMarkdownIncremental(
156
+ '',
157
+ productionState,
158
+ options,
159
+ );
160
+ for (let end = 1; end <= text.length; end++) {
161
+ const prefix = trimStreamingArtifacts(text.slice(0, end), options);
162
+ productionResult = parseMarkdownIncremental(
163
+ prefix,
164
+ productionState,
165
+ options,
166
+ );
167
+ }
168
+ expect(productionResult).toEqual(full);
169
+
170
+ // Every possible two-chunk split converges to the same complete tree,
171
+ // both directly and through Markdown's production trimming pass.
172
+ for (let split = 0; split <= text.length; split++) {
173
+ const state = createIncrementalState<true>();
174
+ parseMarkdownIncremental(text.slice(0, split), state, options);
175
+ expect(parseMarkdownIncremental(text, state, options)).toEqual(full);
176
+
177
+ const trimmedState = createIncrementalState<true>();
178
+ parseMarkdownIncremental(
179
+ trimStreamingArtifacts(text.slice(0, split), options),
180
+ trimmedState,
181
+ options,
182
+ );
183
+ expect(
184
+ parseMarkdownIncremental(
185
+ trimStreamingArtifacts(text, options),
186
+ trimmedState,
187
+ options,
188
+ ),
189
+ ).toEqual(full);
190
+ }
191
+ },
192
+ );
193
+ });
194
+
195
+ describe.each([
196
+ ['list', '- $$\n x\n- next\n\nAfter'],
197
+ ['blockquote', '> $$\n> x\n\nAfter'],
198
+ ['deeper blockquote', '> $$\n> > x\n> > $$'],
199
+ ['deeper blockquote in a list', '- > $$\n > > x\n > > $$'],
200
+ ['deeper inner quote in a quoted list', '> - $$\n> > x\n> > $$'],
201
+ ] as const)('unmatched display math after a %s ends', (_label, text) => {
202
+ it.each([false, true])(
203
+ 'returns to literal parsing (sourceRanges=%s)',
204
+ sourceRanges => {
205
+ const options = {math: true as const, sourceRanges};
206
+ const full = parseMarkdown(text, options);
207
+ for (const trimsStreamingArtifacts of [false, true]) {
208
+ const state = createIncrementalState<true>();
209
+ let result = parseMarkdownIncremental('', state, options);
210
+ for (let end = 1; end <= text.length; end++) {
211
+ const prefix = text.slice(0, end);
212
+ result = parseMarkdownIncremental(
213
+ trimsStreamingArtifacts
214
+ ? trimStreamingArtifacts(prefix, options)
215
+ : prefix,
216
+ state,
217
+ options,
218
+ );
219
+ }
220
+ expect(result).toEqual(full);
221
+ }
222
+ },
223
+ );
224
+ });
225
+
226
+ it('withholds an incomplete display-math block while streaming', () => {
227
+ const state = createIncrementalState<true>();
228
+ const blocks = parseMarkdownIncremental('Intro\n\n$$\nx + y', state, {
229
+ math: true,
230
+ });
231
+ expect(blocks).toEqual(parseMarkdown('Intro'));
232
+ });
233
+
234
+ it('stores math nodes only in a math-enabled incremental state', () => {
235
+ const text = 'Intro.\n\nInline $x$.';
236
+ const state = createIncrementalState<true>();
237
+ const blocks = parseMarkdownIncremental(text, state, {math: true});
238
+ expect(blocks).toEqual(parseMarkdown(text, {math: true}));
239
+ expect(state.settledBlocks).toEqual(parseMarkdown('Intro.', {math: true}));
240
+ });
241
+
93
242
  it('handles table streaming', () => {
94
243
  const text = '| Col1 | Col2 |\n| --- | --- |\n| a | b |\n| c | d |';
95
244
  const {final} = simulateStreaming(text, 5);
@@ -333,6 +482,15 @@ describe('trimStreamingArtifacts', () => {
333
482
  expect(trimStreamingArtifacts('Hello `code`')).toBe('Hello `code`');
334
483
  });
335
484
 
485
+ it('withholds incomplete inline math only when math parsing is enabled', () => {
486
+ expect(trimStreamingArtifacts('Value $', {math: true})).toBe('Value ');
487
+ expect(trimStreamingArtifacts('Value $x + 1', {math: true})).toBe('Value ');
488
+ expect(trimStreamingArtifacts('Value $x + 1')).toBe('Value $x + 1');
489
+ expect(trimStreamingArtifacts('Value $x + 1$', {math: true})).toBe(
490
+ 'Value $x + 1$',
491
+ );
492
+ });
493
+
336
494
  it('trims trailing unclosed strikethrough', () => {
337
495
  expect(trimStreamingArtifacts('Hello ~~')).toBe('Hello ');
338
496
  expect(trimStreamingArtifacts('Hello ~')).toBe('Hello ');
@@ -456,14 +614,16 @@ describe('streaming structural suppression', () => {
456
614
  });
457
615
 
458
616
  /** The text a reader would see for one block. */
459
- function visibleText(block: BlockNode): string {
460
- const fromInline = (nodes: InlineNode[]): string =>
617
+ function visibleText(block: BlockNodeWithMath): string {
618
+ const fromInline = (nodes: InlineNodeWithMath[]): string =>
461
619
  nodes
462
620
  .map(node => {
463
621
  switch (node.type) {
464
622
  case 'text':
465
623
  case 'code':
466
624
  return node.content;
625
+ case 'math':
626
+ return node.value;
467
627
  case 'bold':
468
628
  case 'italic':
469
629
  case 'strikethrough':
@@ -484,6 +644,8 @@ describe('streaming structural suppression', () => {
484
644
  return fromInline(block.children);
485
645
  case 'codeblock':
486
646
  return block.content;
647
+ case 'math':
648
+ return block.value;
487
649
  case 'blockquote':
488
650
  return block.children.map(visibleText).join('\n');
489
651
  case 'list':
@@ -637,7 +799,7 @@ describe('streaming end-to-end: no raw syntax visible', () => {
637
799
  * Helper: extract all visible text from a block tree.
638
800
  * Returns the text that would be rendered to the user, without markdown syntax.
639
801
  */
640
- function extractVisibleText(blocks: BlockNode[]): string {
802
+ function extractVisibleText(blocks: BlockNodeWithMath[]): string {
641
803
  let text = '';
642
804
  for (const block of blocks) {
643
805
  switch (block.type) {
@@ -648,6 +810,9 @@ describe('streaming end-to-end: no raw syntax visible', () => {
648
810
  case 'codeblock':
649
811
  text += block.content;
650
812
  break;
813
+ case 'math':
814
+ text += block.value;
815
+ break;
651
816
  case 'blockquote':
652
817
  text += extractVisibleText(block.children);
653
818
  break;
@@ -676,7 +841,7 @@ describe('streaming end-to-end: no raw syntax visible', () => {
676
841
  return text;
677
842
  }
678
843
 
679
- function extractInlineText(nodes: InlineNode[]): string {
844
+ function extractInlineText(nodes: InlineNodeWithMath[]): string {
680
845
  let text = '';
681
846
  for (const node of nodes) {
682
847
  switch (node.type) {
@@ -686,6 +851,9 @@ describe('streaming end-to-end: no raw syntax visible', () => {
686
851
  case 'code':
687
852
  text += node.content;
688
853
  break;
854
+ case 'math':
855
+ text += node.value;
856
+ break;
689
857
  case 'bold':
690
858
  case 'italic':
691
859
  case 'strikethrough':
@@ -707,11 +875,11 @@ describe('streaming end-to-end: no raw syntax visible', () => {
707
875
  }
708
876
 
709
877
  function streamCharByChar(fullText: string): {
710
- snapshots: BlockNode[][];
878
+ snapshots: BlockNodeWithMath[][];
711
879
  visibleTexts: string[];
712
880
  } {
713
881
  const state = createIncrementalState();
714
- const snapshots: BlockNode[][] = [];
882
+ const snapshots: BlockNodeWithMath[][] = [];
715
883
  const visibleTexts: string[] = [];
716
884
  for (let i = 1; i <= fullText.length; i++) {
717
885
  const trimmed = trimStreamingArtifacts(fullText.slice(0, i));
@@ -24,10 +24,16 @@ export {
24
24
  } from './parser';
25
25
  export type {
26
26
  BlockNode,
27
+ BlockNodeWithMath,
28
+ MathBlockNode,
27
29
  InlineNode,
30
+ InlineNodeWithMath,
31
+ MathInlineNode,
28
32
  SourceRange,
29
33
  ListItemNode,
30
34
  TableCellNode,
31
35
  TableAlignment,
36
+ ParseOptions,
37
+ MathParseOptions,
32
38
  IncrementalState as IncrementalParseState,
33
39
  } from './parser';
@@ -9,6 +9,8 @@ import {
9
9
  } from './parser';
10
10
  import type {BlockNode, ParseOptions} from './parser';
11
11
 
12
+ type PerfParseOptions = ParseOptions;
13
+
12
14
  function generateAIResponse(paragraphs: number): string {
13
15
  const sections: string[] = [];
14
16
  for (let i = 0; i < paragraphs; i++) {
@@ -81,7 +83,7 @@ function simulateStreamingIncremental(
81
83
  function blocksBuiltPerChunk(
82
84
  fullText: string,
83
85
  chunkSize: number,
84
- options?: ParseOptions,
86
+ options?: PerfParseOptions,
85
87
  ): number[] {
86
88
  const state = createIncrementalState();
87
89
  let previous: ReadonlySet<BlockNode> = new Set();