@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
|
@@ -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
|
|
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 {
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
1033
|
-
rows: {children:
|
|
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:
|
|
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<
|
|
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
|
|
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
|
|
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
|
-
|
|
1656
|
-
|
|
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
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
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
|
|
1677
|
-
|
|
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<
|
|
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
|
|
1705
|
-
|
|
1706
|
-
|
|
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<
|
|
1723
|
-
const prevInlineNodesRef = useRef<
|
|
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 {
|
|
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:
|
|
460
|
-
const fromInline = (nodes:
|
|
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:
|
|
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:
|
|
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:
|
|
878
|
+
snapshots: BlockNodeWithMath[][];
|
|
711
879
|
visibleTexts: string[];
|
|
712
880
|
} {
|
|
713
881
|
const state = createIncrementalState();
|
|
714
|
-
const snapshots:
|
|
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));
|
package/src/Markdown/index.ts
CHANGED
|
@@ -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?:
|
|
86
|
+
options?: PerfParseOptions,
|
|
85
87
|
): number[] {
|
|
86
88
|
const state = createIncrementalState();
|
|
87
89
|
let previous: ReadonlySet<BlockNode> = new Set();
|