react-native-nitro-markdown 0.4.3 → 0.5.0

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 (91) hide show
  1. package/README.md +351 -22
  2. package/android/src/main/java/com/margelo/nitro/com/nitromarkdown/HybridMarkdownSession.kt +27 -8
  3. package/cpp/bindings/HybridMarkdownParser.cpp +216 -66
  4. package/cpp/bindings/HybridMarkdownParser.hpp +2 -0
  5. package/ios/HybridMarkdownSession.swift +33 -7
  6. package/lib/commonjs/headless.js +41 -5
  7. package/lib/commonjs/headless.js.map +1 -1
  8. package/lib/commonjs/index.js.map +1 -1
  9. package/lib/commonjs/markdown-stream.js +107 -13
  10. package/lib/commonjs/markdown-stream.js.map +1 -1
  11. package/lib/commonjs/markdown.js +180 -25
  12. package/lib/commonjs/markdown.js.map +1 -1
  13. package/lib/commonjs/renderers/code.js +1 -0
  14. package/lib/commonjs/renderers/code.js.map +1 -1
  15. package/lib/commonjs/renderers/table.js +116 -24
  16. package/lib/commonjs/renderers/table.js.map +1 -1
  17. package/lib/commonjs/utils/incremental-ast.js +153 -0
  18. package/lib/commonjs/utils/incremental-ast.js.map +1 -0
  19. package/lib/module/headless.js +37 -4
  20. package/lib/module/headless.js.map +1 -1
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/markdown-stream.js +108 -14
  23. package/lib/module/markdown-stream.js.map +1 -1
  24. package/lib/module/markdown.js +182 -27
  25. package/lib/module/markdown.js.map +1 -1
  26. package/lib/module/renderers/code.js +1 -0
  27. package/lib/module/renderers/code.js.map +1 -1
  28. package/lib/module/renderers/table.js +116 -24
  29. package/lib/module/renderers/table.js.map +1 -1
  30. package/lib/module/utils/incremental-ast.js +147 -0
  31. package/lib/module/utils/incremental-ast.js.map +1 -0
  32. package/lib/typescript/commonjs/Markdown.nitro.d.ts +2 -0
  33. package/lib/typescript/commonjs/Markdown.nitro.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/headless.d.ts +13 -0
  35. package/lib/typescript/commonjs/headless.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/index.d.ts +2 -0
  37. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/markdown-stream.d.ts +6 -1
  39. package/lib/typescript/commonjs/markdown-stream.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/markdown.d.ts +53 -1
  41. package/lib/typescript/commonjs/markdown.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/renderers/code.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/renderers/table.d.ts +1 -1
  44. package/lib/typescript/commonjs/renderers/table.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/specs/MarkdownSession.nitro.d.ts +5 -2
  46. package/lib/typescript/commonjs/specs/MarkdownSession.nitro.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/utils/incremental-ast.d.ts +12 -0
  48. package/lib/typescript/commonjs/utils/incremental-ast.d.ts.map +1 -0
  49. package/lib/typescript/module/Markdown.nitro.d.ts +2 -0
  50. package/lib/typescript/module/Markdown.nitro.d.ts.map +1 -1
  51. package/lib/typescript/module/headless.d.ts +13 -0
  52. package/lib/typescript/module/headless.d.ts.map +1 -1
  53. package/lib/typescript/module/index.d.ts +2 -0
  54. package/lib/typescript/module/index.d.ts.map +1 -1
  55. package/lib/typescript/module/markdown-stream.d.ts +6 -1
  56. package/lib/typescript/module/markdown-stream.d.ts.map +1 -1
  57. package/lib/typescript/module/markdown.d.ts +53 -1
  58. package/lib/typescript/module/markdown.d.ts.map +1 -1
  59. package/lib/typescript/module/renderers/code.d.ts.map +1 -1
  60. package/lib/typescript/module/renderers/table.d.ts +1 -1
  61. package/lib/typescript/module/renderers/table.d.ts.map +1 -1
  62. package/lib/typescript/module/specs/MarkdownSession.nitro.d.ts +5 -2
  63. package/lib/typescript/module/specs/MarkdownSession.nitro.d.ts.map +1 -1
  64. package/lib/typescript/module/utils/incremental-ast.d.ts +12 -0
  65. package/lib/typescript/module/utils/incremental-ast.d.ts.map +1 -0
  66. package/nitrogen/generated/android/NitroMarkdownOnLoad.cpp +2 -0
  67. package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
  68. package/nitrogen/generated/android/c++/JHybridMarkdownSessionSpec.cpp +18 -6
  69. package/nitrogen/generated/android/c++/JHybridMarkdownSessionSpec.hpp +4 -2
  70. package/nitrogen/generated/android/kotlin/com/margelo/nitro/com/nitromarkdown/Func_void_double_double.kt +80 -0
  71. package/nitrogen/generated/android/kotlin/com/margelo/nitro/com/nitromarkdown/HybridMarkdownSessionSpec.kt +11 -3
  72. package/nitrogen/generated/ios/NitroMarkdown-Swift-Cxx-Bridge.cpp +8 -0
  73. package/nitrogen/generated/ios/NitroMarkdown-Swift-Cxx-Bridge.hpp +31 -0
  74. package/nitrogen/generated/ios/c++/HybridMarkdownSessionSpecSwift.hpp +20 -2
  75. package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
  76. package/nitrogen/generated/ios/swift/HybridMarkdownSessionSpec.swift +4 -2
  77. package/nitrogen/generated/ios/swift/HybridMarkdownSessionSpec_cxx.swift +34 -9
  78. package/nitrogen/generated/shared/c++/HybridMarkdownParserSpec.cpp +2 -0
  79. package/nitrogen/generated/shared/c++/HybridMarkdownParserSpec.hpp +2 -0
  80. package/nitrogen/generated/shared/c++/HybridMarkdownSessionSpec.cpp +2 -0
  81. package/nitrogen/generated/shared/c++/HybridMarkdownSessionSpec.hpp +4 -2
  82. package/package.json +4 -3
  83. package/src/Markdown.nitro.ts +2 -0
  84. package/src/headless.ts +42 -4
  85. package/src/index.ts +7 -0
  86. package/src/markdown-stream.tsx +163 -15
  87. package/src/markdown.tsx +339 -24
  88. package/src/renderers/code.tsx +5 -1
  89. package/src/renderers/table.tsx +212 -66
  90. package/src/specs/MarkdownSession.nitro.ts +6 -2
  91. package/src/utils/incremental-ast.ts +224 -0
@@ -18,8 +18,8 @@ import {
18
18
  type ViewStyle,
19
19
  type LayoutChangeEvent,
20
20
  } from "react-native";
21
+ import { getTextContent, type MarkdownNode } from "../headless";
21
22
  import { useMarkdownContext, type NodeRendererProps } from "../MarkdownContext";
22
- import type { MarkdownNode } from "../headless";
23
23
  import type { MarkdownTheme } from "../theme";
24
24
 
25
25
  type TableData = {
@@ -66,15 +66,86 @@ type TableRendererProps = {
66
66
  };
67
67
 
68
68
  type ColumnWidthsAction = {
69
- type: "SET_WIDTHS";
69
+ type: "RESET_WIDTHS" | "SET_MONOTONIC_WIDTHS";
70
70
  payload: number[];
71
71
  };
72
72
 
73
+ const MIN_COLUMN_WIDTH = 60;
74
+ const COLUMN_MEASUREMENT_PADDING = 8;
75
+ const APPROX_CHAR_WIDTH = 7;
76
+ const MAX_ESTIMATED_CHARS = 120;
77
+ const ESTIMATED_WIDTH_STEP = 24;
78
+ const MEASUREMENT_STABILIZE_MS = 140;
79
+ const IS_ACT_TEST_ENVIRONMENT =
80
+ Reflect.get(globalThis, "IS_REACT_ACT_ENVIRONMENT") === true;
81
+ const SHOULD_DEBOUNCE_MEASUREMENT = !IS_ACT_TEST_ENVIRONMENT;
82
+
73
83
  const columnWidthsReducer = (state: number[], action: ColumnWidthsAction) => {
74
- if (action.type === "SET_WIDTHS") return action.payload;
84
+ if (action.type === "RESET_WIDTHS") {
85
+ if (
86
+ state.length === action.payload.length &&
87
+ state.every((width, index) => width === action.payload[index])
88
+ ) {
89
+ return state;
90
+ }
91
+ return action.payload;
92
+ }
93
+
94
+ if (action.type === "SET_MONOTONIC_WIDTHS") {
95
+ if (state.length !== action.payload.length) {
96
+ return action.payload;
97
+ }
98
+
99
+ const merged = action.payload.map((width, index) =>
100
+ Math.max(width, state[index] ?? width),
101
+ );
102
+
103
+ if (state.every((width, index) => width === merged[index])) {
104
+ return state;
105
+ }
106
+ return merged;
107
+ }
108
+
75
109
  return state;
76
110
  };
77
111
 
112
+ const estimateColumnWidths = (
113
+ headers: MarkdownNode[],
114
+ rows: MarkdownNode[][],
115
+ columnCount: number,
116
+ ) => {
117
+ const widths = new Array<number>(columnCount).fill(MIN_COLUMN_WIDTH);
118
+
119
+ for (let col = 0; col < columnCount; col++) {
120
+ const headerChars = Math.min(
121
+ getTextContent(headers[col] ?? { type: "text", content: "" }).trim()
122
+ .length,
123
+ MAX_ESTIMATED_CHARS,
124
+ );
125
+ let maxChars = headerChars;
126
+
127
+ for (let row = 0; row < rows.length; row++) {
128
+ const cell = rows[row][col];
129
+ if (!cell) continue;
130
+ const cellChars = Math.min(
131
+ getTextContent(cell).trim().length,
132
+ MAX_ESTIMATED_CHARS,
133
+ );
134
+ if (cellChars > maxChars) {
135
+ maxChars = cellChars;
136
+ }
137
+ }
138
+
139
+ const estimatedWidth =
140
+ maxChars * APPROX_CHAR_WIDTH + COLUMN_MEASUREMENT_PADDING;
141
+ const steppedEstimatedWidth =
142
+ Math.ceil(estimatedWidth / ESTIMATED_WIDTH_STEP) * ESTIMATED_WIDTH_STEP;
143
+ widths[col] = Math.max(MIN_COLUMN_WIDTH, steppedEstimatedWidth);
144
+ }
145
+
146
+ return widths;
147
+ };
148
+
78
149
  export const TableRenderer: FC<TableRendererProps> = ({
79
150
  node,
80
151
  Renderer,
@@ -88,11 +159,27 @@ export const TableRenderer: FC<TableRendererProps> = ({
88
159
 
89
160
  const columnCount = headers.length;
90
161
  const styles = useMemo(() => createTableStyles(theme), [theme]);
162
+ const estimatedColumnWidths = useMemo(
163
+ () => estimateColumnWidths(headers, rows, columnCount),
164
+ [headers, rows, columnCount],
165
+ );
91
166
 
92
- const [columnWidths, dispatch] = useReducer(columnWidthsReducer, []);
167
+ const [columnWidths, dispatch] = useReducer(
168
+ columnWidthsReducer,
169
+ estimatedColumnWidths,
170
+ );
93
171
  const measuredWidths = useRef<Map<string, number>>(new Map());
94
172
  const measuredCells = useRef<Set<string>>(new Set());
95
173
  const widthsCalculated = useRef(false);
174
+ const columnWidthsRef = useRef(columnWidths);
175
+ const lastCellKeySignatureRef = useRef("");
176
+ const measurementTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
177
+ null,
178
+ );
179
+ const [needsMeasurement, setNeedsMeasurement] = useReducer(
180
+ (_previous: boolean, nextValue: boolean) => nextValue,
181
+ false,
182
+ );
96
183
 
97
184
  const expectedCellKeys = useMemo(() => {
98
185
  const keys: string[] = [];
@@ -110,16 +197,62 @@ export const TableRenderer: FC<TableRendererProps> = ({
110
197
  return keys;
111
198
  }, [headers, rows]);
112
199
 
200
+ const expectedCellKeySignature = useMemo(
201
+ () => expectedCellKeys.join("|"),
202
+ [expectedCellKeys],
203
+ );
204
+
113
205
  useEffect(() => {
114
- measuredWidths.current.clear();
115
- measuredCells.current.clear();
116
- widthsCalculated.current = false;
117
- dispatch({ type: "SET_WIDTHS", payload: [] });
118
- }, [expectedCellKeys]);
206
+ columnWidthsRef.current = columnWidths;
207
+ }, [columnWidths]);
208
+
209
+ useEffect(() => {
210
+ const structureChanged =
211
+ lastCellKeySignatureRef.current !== expectedCellKeySignature;
212
+ lastCellKeySignatureRef.current = expectedCellKeySignature;
213
+
214
+ if (measurementTimerRef.current) {
215
+ clearTimeout(measurementTimerRef.current);
216
+ measurementTimerRef.current = null;
217
+ }
218
+
219
+ if (structureChanged) {
220
+ measuredWidths.current.clear();
221
+ measuredCells.current.clear();
222
+ widthsCalculated.current = false;
223
+ setNeedsMeasurement(false);
224
+ dispatch({ type: "RESET_WIDTHS", payload: estimatedColumnWidths });
225
+ } else {
226
+ dispatch({
227
+ type: "SET_MONOTONIC_WIDTHS",
228
+ payload: estimatedColumnWidths,
229
+ });
230
+ if (widthsCalculated.current) {
231
+ return;
232
+ }
233
+ }
234
+
235
+ if (!SHOULD_DEBOUNCE_MEASUREMENT) {
236
+ setNeedsMeasurement(true);
237
+ return;
238
+ }
239
+
240
+ measurementTimerRef.current = setTimeout(() => {
241
+ measurementTimerRef.current = null;
242
+ setNeedsMeasurement(true);
243
+ }, MEASUREMENT_STABILIZE_MS);
244
+
245
+ return () => {
246
+ if (measurementTimerRef.current) {
247
+ clearTimeout(measurementTimerRef.current);
248
+ measurementTimerRef.current = null;
249
+ }
250
+ };
251
+ }, [estimatedColumnWidths, expectedCellKeySignature]);
119
252
 
120
253
  const onCellLayout = useCallback(
121
254
  (cellKey: string, width: number) => {
122
- if (widthsCalculated.current) return;
255
+ if (width <= 0 || widthsCalculated.current || !needsMeasurement) return;
123
256
 
124
257
  measuredWidths.current.set(cellKey, width);
125
258
  if (!measuredCells.current.has(cellKey)) {
@@ -133,7 +266,7 @@ export const TableRenderer: FC<TableRendererProps> = ({
133
266
  );
134
267
  if (!allCellsMeasured) return;
135
268
 
136
- const maxWidths: number[] = new Array(columnCount).fill(0);
269
+ const maxWidths: number[] = [...columnWidthsRef.current];
137
270
 
138
271
  for (let col = 0; col < columnCount; col++) {
139
272
  const headerWidth = measuredWidths.current.get(`header-${col}`);
@@ -149,13 +282,17 @@ export const TableRenderer: FC<TableRendererProps> = ({
149
282
  }
150
283
  }
151
284
 
152
- maxWidths[col] = Math.max(maxWidths[col] + 8, 60);
285
+ maxWidths[col] = Math.max(
286
+ maxWidths[col] + COLUMN_MEASUREMENT_PADDING,
287
+ MIN_COLUMN_WIDTH,
288
+ );
153
289
  }
154
290
 
155
291
  widthsCalculated.current = true;
156
- dispatch({ type: "SET_WIDTHS", payload: maxWidths });
292
+ setNeedsMeasurement(false);
293
+ dispatch({ type: "RESET_WIDTHS", payload: maxWidths });
157
294
  },
158
- [columnCount, expectedCellKeys, rows],
295
+ [columnCount, expectedCellKeys, needsMeasurement, rows],
159
296
  );
160
297
 
161
298
  const getAlignment = (
@@ -170,10 +307,11 @@ export const TableRenderer: FC<TableRendererProps> = ({
170
307
  if (columnCount === 0) return null;
171
308
 
172
309
  const hasWidths = columnWidths.length === columnCount;
310
+ const resolvedWidths = hasWidths ? columnWidths : estimatedColumnWidths;
173
311
 
174
312
  return (
175
313
  <View style={[styles.container, style]}>
176
- {!hasWidths && (
314
+ {needsMeasurement ? (
177
315
  <View style={styles.measurementContainer}>
178
316
  <View style={styles.measurementRow}>
179
317
  {headers.map((cell, colIndex) => (
@@ -214,24 +352,62 @@ export const TableRenderer: FC<TableRendererProps> = ({
214
352
  </View>
215
353
  ))}
216
354
  </View>
217
- )}
218
-
219
- {hasWidths ? (
220
- <ScrollView
221
- horizontal
222
- showsHorizontalScrollIndicator
223
- style={styles.tableScroll}
224
- bounces={false}
355
+ ) : null}
356
+
357
+ <ScrollView
358
+ horizontal
359
+ showsHorizontalScrollIndicator
360
+ style={styles.tableScroll}
361
+ bounces={false}
362
+ >
363
+ <View
364
+ style={[
365
+ styles.table,
366
+ {
367
+ backgroundColor:
368
+ style?.backgroundColor ?? theme.colors.surface ?? "#111827",
369
+ },
370
+ ]}
225
371
  >
226
- <View style={styles.table}>
227
- <View style={styles.headerRow}>
228
- {headers.map((cell, colIndex) => (
372
+ <View style={styles.headerRow}>
373
+ {headers.map((cell, colIndex) => (
374
+ <View
375
+ key={`header-${colIndex}`}
376
+ style={[
377
+ styles.headerCell,
378
+ {
379
+ width: resolvedWidths[colIndex] ?? MIN_COLUMN_WIDTH,
380
+ alignItems: getAlignment(colIndex),
381
+ },
382
+ colIndex === columnCount - 1 && styles.lastCell,
383
+ ]}
384
+ >
385
+ <CellContent
386
+ node={cell}
387
+ Renderer={Renderer}
388
+ styles={styles}
389
+ textStyle={styles.headerText}
390
+ />
391
+ </View>
392
+ ))}
393
+ </View>
394
+
395
+ {rows.map((row, rowIndex) => (
396
+ <View
397
+ key={`row-${rowIndex}`}
398
+ style={[
399
+ styles.bodyRow,
400
+ rowIndex === rows.length - 1 && styles.lastRow,
401
+ rowIndex % 2 === 0 ? styles.evenRow : styles.oddRow,
402
+ ]}
403
+ >
404
+ {row.map((cell, colIndex) => (
229
405
  <View
230
- key={`header-${colIndex}`}
406
+ key={`cell-${rowIndex}-${colIndex}`}
231
407
  style={[
232
- styles.headerCell,
408
+ styles.bodyCell,
233
409
  {
234
- width: columnWidths[colIndex],
410
+ width: resolvedWidths[colIndex] ?? MIN_COLUMN_WIDTH,
235
411
  alignItems: getAlignment(colIndex),
236
412
  },
237
413
  colIndex === columnCount - 1 && styles.lastCell,
@@ -241,46 +417,14 @@ export const TableRenderer: FC<TableRendererProps> = ({
241
417
  node={cell}
242
418
  Renderer={Renderer}
243
419
  styles={styles}
244
- textStyle={styles.headerText}
420
+ textStyle={styles.cellText}
245
421
  />
246
422
  </View>
247
423
  ))}
248
424
  </View>
249
-
250
- {rows.map((row, rowIndex) => (
251
- <View
252
- key={`row-${rowIndex}`}
253
- style={[
254
- styles.bodyRow,
255
- rowIndex === rows.length - 1 && styles.lastRow,
256
- rowIndex % 2 === 1 && styles.oddRow,
257
- ]}
258
- >
259
- {row.map((cell, colIndex) => (
260
- <View
261
- key={`cell-${rowIndex}-${colIndex}`}
262
- style={[
263
- styles.bodyCell,
264
- {
265
- width: columnWidths[colIndex],
266
- alignItems: getAlignment(colIndex),
267
- },
268
- colIndex === columnCount - 1 && styles.lastCell,
269
- ]}
270
- >
271
- <CellContent
272
- node={cell}
273
- Renderer={Renderer}
274
- styles={styles}
275
- textStyle={styles.cellText}
276
- />
277
- </View>
278
- ))}
279
- </View>
280
- ))}
281
- </View>
282
- </ScrollView>
283
- ) : null}
425
+ ))}
426
+ </View>
427
+ </ScrollView>
284
428
  </View>
285
429
  );
286
430
  };
@@ -339,7 +483,6 @@ const createTableStyles = (theme: MarkdownTheme) => {
339
483
  overflow: "hidden",
340
484
  borderWidth: 1,
341
485
  borderColor: colors.tableBorder || "#374151",
342
- backgroundColor: colors.surface || "#111827",
343
486
  },
344
487
  headerRow: {
345
488
  flexDirection: "row",
@@ -352,6 +495,9 @@ const createTableStyles = (theme: MarkdownTheme) => {
352
495
  borderBottomWidth: 1,
353
496
  borderBottomColor: colors.tableBorder || "#374151",
354
497
  },
498
+ evenRow: {
499
+ backgroundColor: colors.tableRowEven || "transparent",
500
+ },
355
501
  oddRow: {
356
502
  backgroundColor: colors.tableRowOdd || "rgba(255,255,255,0.02)",
357
503
  },
@@ -1,14 +1,18 @@
1
1
  import type { HybridObject } from "react-native-nitro-modules";
2
2
 
3
+ export type MarkdownSessionListener = (from: number, to: number) => void;
4
+
3
5
  export interface MarkdownSession extends HybridObject<{
4
6
  ios: "swift";
5
7
  android: "kotlin";
6
8
  }> {
7
- append(chunk: string): void;
9
+ append(chunk: string): number;
8
10
  clear(): void;
9
11
  getAllText(): string;
12
+ getLength(): number;
13
+ getTextRange(from: number, to: number): string;
10
14
 
11
15
  highlightPosition: number;
12
16
 
13
- addListener(listener: () => void): () => void;
17
+ addListener(listener: MarkdownSessionListener): () => void;
14
18
  }
@@ -0,0 +1,224 @@
1
+ import {
2
+ parseMarkdown,
3
+ parseMarkdownWithOptions,
4
+ type MarkdownNode,
5
+ } from "../headless";
6
+ import type { ParserOptions } from "../Markdown.nitro";
7
+
8
+ const PLAIN_TEXT_APPEND_PATTERN = /[`*_~[\]#!<>()|$\n\r]/;
9
+ const FENCE_LINE_PATTERN = /^ {0,3}(```+|~~~+)/;
10
+
11
+ const parseAst = (text: string, options?: ParserOptions): MarkdownNode => {
12
+ if (options) {
13
+ return parseMarkdownWithOptions(text, options);
14
+ }
15
+ return parseMarkdown(text);
16
+ };
17
+
18
+ const isInsideFencedCodeBlock = (text: string): boolean => {
19
+ const lines = text.split(/\r?\n/);
20
+ let openFenceChar: "`" | "~" | null = null;
21
+ let openFenceLength = 0;
22
+
23
+ for (const line of lines) {
24
+ const fenceMatch = line.match(FENCE_LINE_PATTERN);
25
+ if (!fenceMatch) continue;
26
+
27
+ const marker = fenceMatch[1];
28
+ const markerChar = marker[0] as "`" | "~";
29
+ const markerLength = marker.length;
30
+
31
+ if (!openFenceChar) {
32
+ openFenceChar = markerChar;
33
+ openFenceLength = markerLength;
34
+ continue;
35
+ }
36
+
37
+ if (markerChar === openFenceChar && markerLength >= openFenceLength) {
38
+ openFenceChar = null;
39
+ openFenceLength = 0;
40
+ }
41
+ }
42
+
43
+ return openFenceChar !== null;
44
+ };
45
+
46
+ const containsFenceLine = (text: string): boolean => {
47
+ return text.split(/\r?\n/).some((line) => FENCE_LINE_PATTERN.test(line));
48
+ };
49
+
50
+ const getTrailingLine = (text: string): string => {
51
+ const lastLineBreak = Math.max(
52
+ text.lastIndexOf("\n"),
53
+ text.lastIndexOf("\r"),
54
+ );
55
+ if (lastLineBreak === -1) return text;
56
+ return text.slice(lastLineBreak + 1);
57
+ };
58
+
59
+ const getLeadingLine = (text: string): string => {
60
+ const newlineIndex = text.indexOf("\n");
61
+ const carriageReturnIndex = text.indexOf("\r");
62
+
63
+ const hasNewline = newlineIndex !== -1;
64
+ const hasCarriageReturn = carriageReturnIndex !== -1;
65
+
66
+ if (!hasNewline && !hasCarriageReturn) return text;
67
+ if (!hasNewline) return text.slice(0, carriageReturnIndex);
68
+ if (!hasCarriageReturn) return text.slice(0, newlineIndex);
69
+
70
+ return text.slice(0, Math.min(newlineIndex, carriageReturnIndex));
71
+ };
72
+
73
+ const hasSplitFenceBoundary = (
74
+ previousText: string,
75
+ appendedChunk: string,
76
+ ): boolean => {
77
+ if (appendedChunk.length === 0) return false;
78
+
79
+ const candidateLine = `${getTrailingLine(previousText)}${getLeadingLine(
80
+ appendedChunk,
81
+ )}`;
82
+ if (candidateLine.length === 0) return false;
83
+
84
+ return FENCE_LINE_PATTERN.test(candidateLine);
85
+ };
86
+
87
+ const findTrailingLeafPath = (
88
+ node: MarkdownNode,
89
+ path: number[] = [],
90
+ ): number[] => {
91
+ const children = node.children;
92
+ if (!children || children.length === 0) {
93
+ return path;
94
+ }
95
+
96
+ const lastIndex = children.length - 1;
97
+ return findTrailingLeafPath(children[lastIndex], [...path, lastIndex]);
98
+ };
99
+
100
+ const getNodeAtPath = (
101
+ node: MarkdownNode,
102
+ path: readonly number[],
103
+ ): MarkdownNode | null => {
104
+ let current: MarkdownNode = node;
105
+ for (const index of path) {
106
+ const child = current.children?.[index];
107
+ if (!child) return null;
108
+ current = child;
109
+ }
110
+ return current;
111
+ };
112
+
113
+ const appendPlainTextToAst = (
114
+ ast: MarkdownNode,
115
+ appendedChunk: string,
116
+ previousTextLength: number,
117
+ ): MarkdownNode | null => {
118
+ if (appendedChunk.length === 0) return ast;
119
+ const path = findTrailingLeafPath(ast);
120
+ const leaf = getNodeAtPath(ast, path);
121
+ if (leaf?.type !== "text") return null;
122
+ if (typeof leaf.end !== "number" || leaf.end !== previousTextLength) {
123
+ return null;
124
+ }
125
+
126
+ const delta = appendedChunk.length;
127
+ const update = (node: MarkdownNode, depth: number): MarkdownNode => {
128
+ if (depth === path.length) {
129
+ return {
130
+ ...node,
131
+ content: (node.content ?? "") + appendedChunk,
132
+ end: typeof node.end === "number" ? node.end + delta : node.end,
133
+ };
134
+ }
135
+
136
+ const childIndex = path[depth];
137
+ const children = node.children?.map((child, index) =>
138
+ index === childIndex ? update(child, depth + 1) : child,
139
+ );
140
+
141
+ return {
142
+ ...node,
143
+ end: typeof node.end === "number" ? node.end + delta : node.end,
144
+ children,
145
+ };
146
+ };
147
+
148
+ return update(ast, 0);
149
+ };
150
+
151
+ const endsAtBlockBoundary = (text: string): boolean => {
152
+ return text.endsWith("\n") || text.endsWith("\r");
153
+ };
154
+
155
+ export type IncrementalAstInput = {
156
+ allowIncremental?: boolean;
157
+ nextText: string;
158
+ options?: ParserOptions;
159
+ previousAst: MarkdownNode;
160
+ previousText: string;
161
+ };
162
+
163
+ export const getNextStreamAst = ({
164
+ allowIncremental = true,
165
+ nextText,
166
+ options,
167
+ previousAst,
168
+ previousText,
169
+ }: IncrementalAstInput): MarkdownNode => {
170
+ if (!allowIncremental || !nextText.startsWith(previousText)) {
171
+ return parseAst(nextText, options);
172
+ }
173
+
174
+ const appendedChunk = nextText.slice(previousText.length);
175
+ if (appendedChunk.length === 0) {
176
+ return previousAst;
177
+ }
178
+
179
+ const insideFencedCodeBlock = isInsideFencedCodeBlock(previousText);
180
+ const hasFenceBoundary =
181
+ containsFenceLine(appendedChunk) ||
182
+ hasSplitFenceBoundary(previousText, appendedChunk);
183
+
184
+ if (insideFencedCodeBlock && !hasFenceBoundary) {
185
+ const fencedTextAppendAst = appendPlainTextToAst(
186
+ previousAst,
187
+ appendedChunk,
188
+ previousText.length,
189
+ );
190
+ if (fencedTextAppendAst) {
191
+ return fencedTextAppendAst;
192
+ }
193
+ }
194
+
195
+ if (!PLAIN_TEXT_APPEND_PATTERN.test(appendedChunk)) {
196
+ if (endsAtBlockBoundary(previousText)) {
197
+ return parseAst(nextText, options);
198
+ }
199
+
200
+ const textAppendedAst = appendPlainTextToAst(
201
+ previousAst,
202
+ appendedChunk,
203
+ previousText.length,
204
+ );
205
+ if (textAppendedAst) {
206
+ return textAppendedAst;
207
+ }
208
+ }
209
+
210
+ if (insideFencedCodeBlock) {
211
+ return parseAst(nextText, options);
212
+ }
213
+
214
+ // Correctness-first fallback: full reparse for all non-trivial appends.
215
+ // Incremental append is only used for plain text chunks at the true trailing leaf.
216
+ return parseAst(nextText, options);
217
+ };
218
+
219
+ export const parseMarkdownAst = (
220
+ text: string,
221
+ options?: ParserOptions,
222
+ ): MarkdownNode => {
223
+ return parseAst(text, options);
224
+ };