@fuzdev/mdz 0.1.0 → 0.3.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 (77) hide show
  1. package/README.md +4 -0
  2. package/dist/Mdz.svelte +15 -7
  3. package/dist/Mdz.svelte.d.ts +9 -2
  4. package/dist/Mdz.svelte.d.ts.map +1 -1
  5. package/dist/MdzNodeView.svelte +130 -95
  6. package/dist/MdzNodeView.svelte.d.ts +6 -2
  7. package/dist/MdzNodeView.svelte.d.ts.map +1 -1
  8. package/dist/MdzStream.svelte +3 -3
  9. package/dist/MdzStream.svelte.d.ts.map +1 -1
  10. package/dist/MdzStreamNodeView.svelte +129 -95
  11. package/dist/MdzStreamNodeView.svelte.d.ts +6 -2
  12. package/dist/MdzStreamNodeView.svelte.d.ts.map +1 -1
  13. package/dist/context_helpers.d.ts +1 -1
  14. package/dist/mdz.d.ts +59 -2
  15. package/dist/mdz.d.ts.map +1 -1
  16. package/dist/mdz.js +37 -1
  17. package/dist/mdz_debug_work.d.ts +38 -0
  18. package/dist/mdz_debug_work.d.ts.map +1 -0
  19. package/dist/mdz_debug_work.js +37 -0
  20. package/dist/mdz_helpers.d.ts +96 -1
  21. package/dist/mdz_helpers.d.ts.map +1 -1
  22. package/dist/mdz_helpers.js +192 -0
  23. package/dist/mdz_lexer.d.ts +47 -1
  24. package/dist/mdz_lexer.d.ts.map +1 -1
  25. package/dist/mdz_lexer.js +364 -15
  26. package/dist/mdz_opcodes.d.ts +6 -1
  27. package/dist/mdz_opcodes.d.ts.map +1 -1
  28. package/dist/mdz_opcodes_to_nodes.d.ts.map +1 -1
  29. package/dist/mdz_opcodes_to_nodes.js +26 -0
  30. package/dist/mdz_stream_parser.d.ts.map +1 -1
  31. package/dist/mdz_stream_parser.js +20 -1
  32. package/dist/mdz_stream_parser_inline.d.ts.map +1 -1
  33. package/dist/mdz_stream_parser_inline.js +19 -17
  34. package/dist/mdz_stream_parser_link.d.ts.map +1 -1
  35. package/dist/mdz_stream_parser_link.js +88 -56
  36. package/dist/mdz_stream_parser_list.d.ts.map +1 -1
  37. package/dist/mdz_stream_parser_list.js +32 -3
  38. package/dist/mdz_stream_parser_state.d.ts +41 -0
  39. package/dist/mdz_stream_parser_state.d.ts.map +1 -1
  40. package/dist/mdz_stream_parser_state.js +53 -3
  41. package/dist/mdz_stream_parser_table.d.ts +73 -0
  42. package/dist/mdz_stream_parser_table.d.ts.map +1 -0
  43. package/dist/mdz_stream_parser_table.js +362 -0
  44. package/dist/mdz_stream_parser_text.d.ts.map +1 -1
  45. package/dist/mdz_stream_parser_text.js +35 -24
  46. package/dist/mdz_stream_state.svelte.d.ts +5 -0
  47. package/dist/mdz_stream_state.svelte.d.ts.map +1 -1
  48. package/dist/mdz_stream_state.svelte.js +8 -0
  49. package/dist/mdz_to_svelte.d.ts.map +1 -1
  50. package/dist/mdz_to_svelte.js +51 -20
  51. package/dist/mdz_token_parser.d.ts +16 -0
  52. package/dist/mdz_token_parser.d.ts.map +1 -1
  53. package/dist/mdz_token_parser.js +112 -14
  54. package/dist/svelte_preprocess_mdz.js +33 -39
  55. package/package.json +11 -8
  56. package/src/lib/context_helpers.ts +1 -1
  57. package/src/lib/mdz.ts +79 -3
  58. package/src/lib/mdz_contexts.ts +1 -1
  59. package/src/lib/mdz_debug_work.ts +38 -0
  60. package/src/lib/mdz_helpers.ts +222 -1
  61. package/src/lib/mdz_lexer.ts +420 -21
  62. package/src/lib/mdz_opcodes.ts +10 -1
  63. package/src/lib/mdz_opcodes_to_nodes.ts +37 -3
  64. package/src/lib/mdz_stream_parser.ts +26 -9
  65. package/src/lib/mdz_stream_parser_block.ts +2 -2
  66. package/src/lib/mdz_stream_parser_inline.ts +25 -21
  67. package/src/lib/mdz_stream_parser_link.ts +92 -59
  68. package/src/lib/mdz_stream_parser_list.ts +34 -6
  69. package/src/lib/mdz_stream_parser_state.ts +87 -5
  70. package/src/lib/mdz_stream_parser_table.ts +420 -0
  71. package/src/lib/mdz_stream_parser_text.ts +40 -39
  72. package/src/lib/mdz_stream_parser_url.ts +2 -2
  73. package/src/lib/mdz_stream_state.svelte.ts +8 -1
  74. package/src/lib/mdz_to_svelte.ts +49 -23
  75. package/src/lib/mdz_token_parser.ts +135 -21
  76. package/src/lib/svelte_preprocess_mdz.ts +37 -38
  77. package/src/lib/tsdoc_mdz.ts +1 -1
@@ -8,8 +8,10 @@
8
8
  * @module
9
9
  */
10
10
 
11
- import type {MdzNodeTypeContainer, MdzNodeId, MdzOpcode} from './mdz_opcodes.js';
12
- import {NEWLINE, has_non_whitespace, mdz_heading_id_from_text} from './mdz_helpers.js';
11
+ import type {MdzNodeTypeContainer, MdzNodeId, MdzOpcode} from './mdz_opcodes.ts';
12
+ import type {MdzTableCellParser} from './mdz.ts';
13
+ import {NEWLINE, has_non_whitespace, mdz_heading_id_from_text} from './mdz_helpers.ts';
14
+ import {mdz_debug_work} from './mdz_debug_work.ts';
13
15
 
14
16
  /**
15
17
  * Tri-state result for `try_*` parser handlers.
@@ -207,6 +209,16 @@ export interface MdzStreamParserState {
207
209
  * quote (prefix stripping + nested-parser feeding) — see `MdzBlockquoteState`.
208
210
  */
209
211
  blockquote: MdzBlockquoteState | null;
212
+ /**
213
+ * The open table, or null. While set, each line is consumed as a body row
214
+ * (or ends the table) by `process_table_line`. Holds the `Table` frame's id
215
+ * so the table can be closed when a non-row line or EOF ends it. `item_indent`
216
+ * is `null` for a top-level table (column-0 rows) or the enclosing list item's
217
+ * marker indent for a table nested as a block child (rows skip the indent and
218
+ * a dedent to that indent ends the table, returning control to the list).
219
+ * `cell_parser` is the table's shared cell parser, rebound per row.
220
+ */
221
+ table: {id: MdzNodeId; item_indent: number | null; cell_parser: MdzTableCellParser} | null;
210
222
  /**
211
223
  * Whether the innermost open ListItem's first inline run is current.
212
224
  * Content then flows directly into the ListItem frame — the sync AST
@@ -234,6 +246,32 @@ export interface MdzStreamParserState {
234
246
  search_memo: Map<string, BufferSearchMemo> | null;
235
247
  /** Open-container counts for `find_open`'s O(1) "nothing open" early exit. */
236
248
  open_counts: OpenInlineCounts;
249
+ /**
250
+ * Count of open `Link`/`Element`/`Component` frames in the current run — the
251
+ * streaming analogue of the sync lexer's `#inline_depth`. Kept as an O(1)
252
+ * counter (not a stack walk, which would reintroduce the deep-stack
253
+ * quadratic `open_counts` exists to avoid) so `try_link_open`/`try_tag_open`
254
+ * can cap nesting at `MAX_INLINE_NESTING_DEPTH` — rendering a deeper `[`/`<`
255
+ * literal, matching the sync cap on pure/symmetric nesting. It counts
256
+ * **optimistic** opens, though, so it diverges from the sync `#inline_depth`
257
+ * (which the sync tag path only bumps after a closer precheck) on an
258
+ * asymmetric cap-saturating prefix — 100+ *unclosed* `<a>` before a valid
259
+ * `[x](/u)` saturates this counter and suppresses the trailing link that sync
260
+ * still forms. Adversarial-only, documented not fixed (streaming can't
261
+ * precheck closers): see the flip-at-cap case in `mdz_nesting_cap.test.ts`.
262
+ * Run-scoped for free like `open_counts`: a
263
+ * run close reverts every inline frame above it, decrementing back to zero.
264
+ */
265
+ open_link_tag_depth: number;
266
+ /**
267
+ * Per-name counts of open Element/Component frames, the tag analogue of
268
+ * `open_counts`: `try_close_tag` bails in O(1) when the closing name isn't
269
+ * open, instead of walking a stack that grows one frame per leaked unclosed
270
+ * `<Tag>`. Lazily created on the first tag open — tag-free inputs never
271
+ * allocate the map. Run-scoped like `open_counts`: run closes revert every
272
+ * inline frame above them, decrementing the counts back to zero.
273
+ */
274
+ open_tag_counts: Map<string, number> | null;
237
275
  /** Whether we're inside a heading (newline ends it). */
238
276
  in_heading: boolean;
239
277
  /** Whether we're inside an optimistic inline Code container. */
@@ -300,11 +338,14 @@ export const create_state = (): MdzStreamParserState => ({
300
338
  codeblock: null,
301
339
  list_levels: [],
302
340
  blockquote: null,
341
+ table: null,
303
342
  in_list_item_run: false,
304
343
  last_empty_item_end: 0,
305
344
  base_offset: 0,
306
345
  search_memo: null,
307
346
  open_counts: {Bold: 0, Italic: 0, Strikethrough: 0, Link: 0},
347
+ open_link_tag_depth: 0,
348
+ open_tag_counts: null,
308
349
  in_heading: false,
309
350
  in_code: false,
310
351
  in_paragraph: false,
@@ -352,6 +393,13 @@ export const push_stack_entry = (
352
393
  } else {
353
394
  bump_open_count(state, node_type, 1);
354
395
  }
396
+ if (tag_name !== undefined) {
397
+ const counts = (state.open_tag_counts ??= new Map());
398
+ counts.set(tag_name, (counts.get(tag_name) ?? 0) + 1);
399
+ }
400
+ // Link/Element/Component contribute to the nesting-depth cap (tag_name is
401
+ // set for Element/Component only, so `Link || tag_name` is exactly the three)
402
+ if (node_type === 'Link' || tag_name !== undefined) state.open_link_tag_depth++;
355
403
  };
356
404
 
357
405
  /**
@@ -384,6 +432,16 @@ const bump_open_count = (
384
432
  export const pop_stack_entry = (state: MdzStreamParserState): StackEntry => {
385
433
  const entry = state.stack.pop()!;
386
434
  bump_open_count(state, entry.node_type, -1);
435
+ if (entry.tag_name !== undefined) {
436
+ const counts = state.open_tag_counts!;
437
+ // drop zero-count keys so a long stream of many distinct tag names can't
438
+ // grow the map unboundedly — the `!counts.get(name)` bail in `try_close_tag`
439
+ // treats a missing key and a 0 count identically, so this is behavior-neutral
440
+ const remaining = counts.get(entry.tag_name)! - 1;
441
+ if (remaining === 0) counts.delete(entry.tag_name);
442
+ else counts.set(entry.tag_name, remaining);
443
+ }
444
+ if (entry.node_type === 'Link' || entry.tag_name !== undefined) state.open_link_tag_depth--;
387
445
  return entry;
388
446
  };
389
447
 
@@ -455,7 +513,11 @@ const memo_index_of = (
455
513
  // rescan only the appended tail, overlapping the old boundary so a
456
514
  // straddling needle isn't missed
457
515
  const rescan_from = Math.max(global_from, memo.searched_to - (needle.length - 1));
458
- const result = state.buffer.indexOf(needle, rescan_from - state.base_offset);
516
+ const local_from = rescan_from - state.base_offset;
517
+ const result = state.buffer.indexOf(needle, local_from);
518
+ if (mdz_debug_work.enabled) {
519
+ mdz_debug_work.total += (result === -1 ? state.buffer.length : result) - local_from;
520
+ }
459
521
  memo.from = global_from;
460
522
  memo.result = result === -1 ? -1 : state.base_offset + result;
461
523
  memo.searched_to = global_end;
@@ -464,6 +526,9 @@ const memo_index_of = (
464
526
  // found result already behind `from` — fall through to a fresh scan
465
527
  }
466
528
  const result = state.buffer.indexOf(needle, from);
529
+ if (mdz_debug_work.enabled) {
530
+ mdz_debug_work.total += (result === -1 ? state.buffer.length : result) - from;
531
+ }
467
532
  memo.from = global_from;
468
533
  memo.result = result === -1 ? -1 : state.base_offset + result;
469
534
  memo.searched_to = global_end;
@@ -509,8 +574,13 @@ export const emit = (state: MdzStreamParserState, op: MdzOpcode): void => {
509
574
  if (top) {
510
575
  top.has_children = true;
511
576
  // propagate non-whitespace marker to the nearest Paragraph so
512
- // whitespace-only paragraphs can be discarded at close
513
- if (op.type === 'void' || has_non_whitespace(op.content)) {
577
+ // whitespace-only paragraphs can be discarded at close — the O(1)
578
+ // no-paragraph check runs first so codeblock/table-cell emissions
579
+ // (never inside a Paragraph) skip the content scan entirely
580
+ if (
581
+ op.type === 'void' ||
582
+ (state.paragraph_stack_idx !== -1 && has_non_whitespace(op.content))
583
+ ) {
514
584
  mark_paragraph_non_whitespace(state);
515
585
  }
516
586
  }
@@ -734,6 +804,18 @@ export const revert_failed_close = (state: MdzStreamParserState, stack_idx: numb
734
804
  const frames_above = state.stack.length - 1 - stack_idx;
735
805
  state.stack.splice(stack_idx, 1);
736
806
  bump_open_count(state, entry.node_type, -1);
807
+ if (entry.tag_name !== undefined) {
808
+ const counts = state.open_tag_counts!;
809
+ // drop zero-count keys so a long stream of many distinct tag names can't
810
+ // grow the map unboundedly — the `!counts.get(name)` bail in `try_close_tag`
811
+ // treats a missing key and a 0 count identically, so this is behavior-neutral
812
+ const remaining = counts.get(entry.tag_name)! - 1;
813
+ if (remaining === 0) counts.delete(entry.tag_name);
814
+ else counts.set(entry.tag_name, remaining);
815
+ }
816
+ // only ever reverts an Italic today, but keep the depth counter honest if a
817
+ // Link/Element/Component is ever failed-closed mid-stack
818
+ if (entry.node_type === 'Link' || entry.tag_name !== undefined) state.open_link_tag_depth--;
737
819
  state.opcodes.push({
738
820
  type: 'revert',
739
821
  id: entry.id,
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Table handlers for the streaming mdz parser.
3
+ *
4
+ * A table is recognized at a column-0 pipe row whose next line is a valid
5
+ * delimiter row with a matching column count — a bounded one-line lookahead:
6
+ * the header row holds (`need_more`) until its delimiter line arrives, then the
7
+ * whole header emits at once. Body rows then stream one per line. Because a row
8
+ * is always fully buffered before it emits (no within-row streaming), each
9
+ * cell's inline content is parsed with the sync reference (`MdzTableCellParser`,
10
+ * one `MdzLexer`/`MdzTokenParser` shared across the whole table's cells,
11
+ * rebound per row) and replayed as opcodes — guaranteeing sync↔stream parity
12
+ * by construction.
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import {DEV} from 'esm-env';
18
+
19
+ import {
20
+ NEWLINE,
21
+ PIPE,
22
+ is_line_whitespace,
23
+ mdz_split_table_row,
24
+ mdz_parse_table_delimiter,
25
+ } from './mdz_helpers.ts';
26
+ import {MdzTableCellParser, type MdzNode, type MdzTableAlign} from './mdz.ts';
27
+ import {
28
+ type MdzStreamParserState,
29
+ type TryResult,
30
+ alloc_id,
31
+ close_paragraph,
32
+ emit,
33
+ flush_text,
34
+ offset,
35
+ pop_stack_entry,
36
+ push_stack_entry,
37
+ } from './mdz_stream_parser_state.ts';
38
+
39
+ /**
40
+ * Try to open a table at a column-0 pipe row. Returns `need_more` until both
41
+ * the header line and its delimiter line are buffered (or, in forced mode,
42
+ * resolvable), `not_match` when the two lines aren't a valid header+delimiter
43
+ * pair, and `consumed` once the table opens (closing any open paragraph first,
44
+ * emitting the header row, and leaving `state.pos` at the first body line).
45
+ *
46
+ * @nodocs
47
+ */
48
+ export const try_table_start = (state: MdzStreamParserState, forced: boolean): TryResult => {
49
+ const start = state.pos;
50
+ const header_end = state.buffer.indexOf('\n', start);
51
+ // a table needs a header line plus a delimiter line — without the header's
52
+ // newline there's no room for a delimiter (at EOF it can never be a table)
53
+ if (header_end === -1) return forced ? 'not_match' : 'need_more';
54
+
55
+ const header_cells = mdz_split_table_row(state.buffer, start, header_end);
56
+ if (header_cells === null) return 'not_match';
57
+
58
+ const delim_start = header_end + 1;
59
+ let delim_end = state.buffer.indexOf('\n', delim_start);
60
+ if (delim_end === -1) {
61
+ if (!forced) return 'need_more'; // delimiter line not complete yet
62
+ delim_end = state.buffer.length;
63
+ }
64
+
65
+ const align = mdz_parse_table_delimiter(state.buffer, delim_start, delim_end);
66
+ if (align === null || align.length !== header_cells.length) return 'not_match';
67
+
68
+ // committed — the delimiter row is structural, never emitted
69
+ flush_text(state);
70
+ close_paragraph(state);
71
+
72
+ const table_id = alloc_id(state);
73
+ const table_start = offset(state, start);
74
+ emit(state, {type: 'open', id: table_id, node_type: 'Table', start: table_start, align});
75
+ push_stack_entry(state, table_id, 'Table', table_start);
76
+ state.table = {
77
+ id: table_id,
78
+ item_indent: null,
79
+ cell_parser: new MdzTableCellParser(state.buffer),
80
+ };
81
+
82
+ emit_table_row(state, header_cells, true, start, header_end);
83
+
84
+ // skip past the delimiter row to the first potential body line
85
+ state.pos = delim_end === state.buffer.length ? delim_end : delim_end + 1;
86
+ state.column = 0;
87
+ state.prev_char = NEWLINE;
88
+ return 'consumed';
89
+ };
90
+
91
+ /**
92
+ * Process the line at `state.pos` while a table is open: emit it as a body row,
93
+ * or close the table when it isn't a valid pipe row. Returns false (need more
94
+ * input) only when a pipe-row line is started but not yet complete. A non-pipe
95
+ * line (including a blank line) ends the table and is left for normal
96
+ * processing.
97
+ *
98
+ * @nodocs
99
+ */
100
+ export const process_table_line = (state: MdzStreamParserState, forced: boolean): boolean => {
101
+ const item_indent = state.table!.item_indent;
102
+ if (item_indent !== null) {
103
+ return process_table_line_in_item(state, forced, item_indent);
104
+ }
105
+
106
+ if (state.pos >= state.buffer.length) {
107
+ // the table may continue with more rows in a later chunk
108
+ if (forced) {
109
+ close_table(state);
110
+ return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ const row_start = state.pos;
116
+ // a body row must start with `|` at column 0; anything else ends the table
117
+ // (decided without waiting for the line's newline)
118
+ if (state.buffer.charCodeAt(row_start) !== PIPE) {
119
+ close_table(state);
120
+ return true;
121
+ }
122
+
123
+ let row_end = state.buffer.indexOf('\n', row_start);
124
+ if (row_end === -1) {
125
+ if (!forced) return false; // pipe-row line not complete — hold
126
+ row_end = state.buffer.length;
127
+ }
128
+
129
+ const cells = mdz_split_table_row(state.buffer, row_start, row_end);
130
+ if (cells === null) {
131
+ // starts with `|` but isn't a valid row (e.g. no trailing pipe) — table ends
132
+ close_table(state);
133
+ return true;
134
+ }
135
+
136
+ emit_table_row(state, cells, false, row_start, row_end);
137
+ state.pos = row_end === state.buffer.length ? row_end : row_end + 1;
138
+ state.column = 0;
139
+ state.prev_char = NEWLINE;
140
+ return true;
141
+ };
142
+
143
+ /**
144
+ * Process body rows of an in-item table. Unlike the top-level path this keeps
145
+ * `state.pos` on the newline that ends each row — a row boundary the buffer
146
+ * never compacts past, since it's the active cursor. So when a dedent (to the
147
+ * item's `marker_indent` or shallower), a blank line, or a non-pipe line ends
148
+ * the table, `close_table` leaves the cursor on that newline and the list's
149
+ * newline handler re-classifies the following line — the same control-return
150
+ * shape as the in-item codeblock, robust across chunk boundaries.
151
+ */
152
+ const process_table_line_in_item = (
153
+ state: MdzStreamParserState,
154
+ forced: boolean,
155
+ item_indent: number,
156
+ ): boolean => {
157
+ if (state.pos >= state.buffer.length) {
158
+ if (forced) {
159
+ close_table(state);
160
+ return true;
161
+ }
162
+ return false;
163
+ }
164
+ // `state.pos` sits on the newline ending the previous row (or the delimiter);
165
+ // the candidate next row is the line after it
166
+ const line_start = state.pos + 1;
167
+ let j = line_start;
168
+ while (j < state.buffer.length && is_line_whitespace(state.buffer.charCodeAt(j))) {
169
+ j++;
170
+ }
171
+ if (j >= state.buffer.length) {
172
+ if (forced) {
173
+ close_table(state);
174
+ return true;
175
+ }
176
+ return false; // indent-only / incomplete line — hold
177
+ }
178
+ if (j - line_start <= item_indent || state.buffer.charCodeAt(j) !== PIPE) {
179
+ // a dedent to the marker (or shallower), a blank line, or a non-pipe line
180
+ // ends the table; the cursor stays on the row-boundary newline so the
181
+ // list resumes via its newline handler
182
+ close_table(state);
183
+ return true;
184
+ }
185
+ let row_end = state.buffer.indexOf('\n', j);
186
+ if (row_end === -1) {
187
+ if (!forced) return false; // pipe-row line not complete — hold
188
+ row_end = state.buffer.length;
189
+ }
190
+ const cells = mdz_split_table_row(state.buffer, j, row_end);
191
+ if (cells === null) {
192
+ close_table(state);
193
+ return true;
194
+ }
195
+ emit_table_row(state, cells, false, j, row_end);
196
+ state.pos = row_end; // stay on this row's terminating newline (or EOF)
197
+ return true;
198
+ };
199
+
200
+ /**
201
+ * Close the open table.
202
+ *
203
+ * @nodocs
204
+ */
205
+ export const close_table = (state: MdzStreamParserState): void => {
206
+ if (!state.table) return;
207
+ const {item_indent} = state.table;
208
+ const entry = pop_stack_entry(state);
209
+ emit(state, {type: 'close', id: entry.id, end: offset(state)});
210
+ state.table = null;
211
+ state.active_text_id = null;
212
+ if (item_indent === null) {
213
+ // top-level: the trailing newline + any blank lines absorb at the loop top
214
+ state.skip_blank_lines = true;
215
+ }
216
+ // in-item: the cursor is left on a row-boundary newline (or EOF). The list's
217
+ // newline handler re-classifies the following line; at EOF, `finish` closes
218
+ // the list. No skip_blank_lines — the list owns blank-line containment.
219
+ };
220
+
221
+ /** A recognized in-item table head: the header row's cells + the delimiter alignment. */
222
+ export interface TableInItemHead {
223
+ header_cells: Array<{start: number; end: number}>;
224
+ header_end: number;
225
+ align: Array<MdzTableAlign>;
226
+ delim_end: number;
227
+ }
228
+
229
+ /**
230
+ * Recognize a table opening as a block child of a list item: a pipe row at
231
+ * `header_first` (the line's indent already skipped) whose next line is a
232
+ * delimiter row, both indented past `marker_indent` (so both stay inside the
233
+ * item). Returns the parsed head, `'pending'` when the two-line lookahead isn't
234
+ * fully buffered, or `null` when it isn't a table.
235
+ *
236
+ * @nodocs
237
+ */
238
+ export const match_table_in_item_head = (
239
+ state: MdzStreamParserState,
240
+ header_first: number,
241
+ marker_indent: number,
242
+ forced: boolean,
243
+ ): TableInItemHead | 'pending' | null => {
244
+ const {buffer} = state;
245
+ const header_end = buffer.indexOf('\n', header_first);
246
+ if (header_end === -1) return forced ? null : 'pending'; // header needs its newline + a delimiter beneath
247
+ const header_cells = mdz_split_table_row(buffer, header_first, header_end);
248
+ if (header_cells === null) return null;
249
+ const delim_line_start = header_end + 1;
250
+ let dj = delim_line_start;
251
+ while (dj < buffer.length && is_line_whitespace(buffer.charCodeAt(dj))) dj++;
252
+ if (dj >= buffer.length) return forced ? null : 'pending'; // delimiter line not buffered yet
253
+ if (dj - delim_line_start <= marker_indent) return null; // delimiter dedented out of the item
254
+ let delim_end = buffer.indexOf('\n', dj);
255
+ if (delim_end === -1) {
256
+ if (!forced) return 'pending'; // delimiter line incomplete
257
+ delim_end = buffer.length;
258
+ }
259
+ const align = mdz_parse_table_delimiter(buffer, dj, delim_end);
260
+ if (align === null || align.length !== header_cells.length) return null;
261
+ return {header_cells, header_end, align, delim_end};
262
+ };
263
+
264
+ /**
265
+ * Open a table as a block child of a list item at `marker_indent`, given a
266
+ * `head` from `match_table_in_item_head`. Emits the `Table` open + header row
267
+ * and enters table mode (with `item_indent`), leaving `state.pos` at the first
268
+ * body line. The caller closes the item's run and pops to the attach level first.
269
+ *
270
+ * @nodocs
271
+ */
272
+ export const open_table_in_item = (
273
+ state: MdzStreamParserState,
274
+ header_first: number,
275
+ marker_indent: number,
276
+ head: TableInItemHead,
277
+ ): void => {
278
+ const table_id = alloc_id(state);
279
+ const table_start = offset(state, header_first);
280
+ emit(state, {
281
+ type: 'open',
282
+ id: table_id,
283
+ node_type: 'Table',
284
+ start: table_start,
285
+ align: head.align,
286
+ });
287
+ push_stack_entry(state, table_id, 'Table', table_start);
288
+ state.table = {
289
+ id: table_id,
290
+ item_indent: marker_indent,
291
+ cell_parser: new MdzTableCellParser(state.buffer),
292
+ };
293
+ emit_table_row(state, head.header_cells, true, header_first, head.header_end);
294
+ // leave the cursor on the delimiter's newline (or EOF) — the in-item body-row
295
+ // handler reads from the line after it and hands back on this same convention
296
+ state.pos = head.delim_end;
297
+ state.column = 0;
298
+ state.prev_char = NEWLINE;
299
+ };
300
+
301
+ /** Emit one table row: open, each cell, close. */
302
+ const emit_table_row = (
303
+ state: MdzStreamParserState,
304
+ cells: Array<{start: number; end: number}>,
305
+ header: boolean,
306
+ row_start: number,
307
+ row_end: number,
308
+ ): void => {
309
+ const row_id = alloc_id(state);
310
+ const row_off = offset(state, row_start);
311
+ emit(state, {type: 'open', id: row_id, node_type: 'TableRow', start: row_off, header});
312
+ push_stack_entry(state, row_id, 'TableRow', row_off);
313
+ // one lexer/parser shared across the whole table rather than allocated per
314
+ // row (or per cell) — rebound per row because the buffer string changes
315
+ // across feeds; the lexer's search memo survives when it doesn't
316
+ const cell_parser = state.table!.cell_parser;
317
+ cell_parser.rebind(state.buffer);
318
+ for (const cell of cells) {
319
+ emit_table_cell(state, cell_parser, cell.start, cell.end);
320
+ }
321
+ pop_stack_entry(state);
322
+ emit(state, {type: 'close', id: row_id, end: offset(state, row_end)});
323
+ state.active_text_id = null;
324
+ };
325
+
326
+ /**
327
+ * Emit one cell: open, its inline content (parsed with the sync reference over
328
+ * the fully-buffered span via the row's shared `cell_parser`), close.
329
+ */
330
+ const emit_table_cell = (
331
+ state: MdzStreamParserState,
332
+ cell_parser: MdzTableCellParser,
333
+ cell_start: number,
334
+ cell_end: number,
335
+ ): void => {
336
+ const cell_id = alloc_id(state);
337
+ const cell_off = offset(state, cell_start);
338
+ emit(state, {type: 'open', id: cell_id, node_type: 'TableCell', start: cell_off});
339
+ push_stack_entry(state, cell_id, 'TableCell', cell_off);
340
+ emit_inline_nodes(state, cell_parser.parse(cell_start, cell_end));
341
+ pop_stack_entry(state);
342
+ emit(state, {type: 'close', id: cell_id, end: offset(state, cell_end)});
343
+ state.active_text_id = null;
344
+ };
345
+
346
+ /**
347
+ * Replay sync-parsed inline nodes as opcodes. Cell content is inline-only;
348
+ * positions are local to `state.buffer` and lifted to global via `offset`.
349
+ */
350
+ const emit_inline_nodes = (state: MdzStreamParserState, nodes: Array<MdzNode>): void => {
351
+ for (const node of nodes) {
352
+ switch (node.type) {
353
+ case 'Text':
354
+ emit(state, {
355
+ type: 'text',
356
+ id: alloc_id(state),
357
+ content: node.content,
358
+ text_type: 'Text',
359
+ start: offset(state, node.start),
360
+ end: offset(state, node.end),
361
+ });
362
+ break;
363
+ case 'Code':
364
+ emit(state, {
365
+ type: 'text',
366
+ id: alloc_id(state),
367
+ content: node.content,
368
+ text_type: 'Code',
369
+ start: offset(state, node.start),
370
+ end: offset(state, node.end),
371
+ });
372
+ break;
373
+ case 'Bold':
374
+ case 'Italic':
375
+ case 'Strikethrough': {
376
+ const id = alloc_id(state);
377
+ const s = offset(state, node.start);
378
+ emit(state, {type: 'open', id, node_type: node.type, start: s});
379
+ push_stack_entry(state, id, node.type, s);
380
+ emit_inline_nodes(state, node.children);
381
+ pop_stack_entry(state);
382
+ emit(state, {type: 'close', id, end: offset(state, node.end)});
383
+ break;
384
+ }
385
+ case 'Link': {
386
+ const id = alloc_id(state);
387
+ const s = offset(state, node.start);
388
+ emit(state, {type: 'open', id, node_type: 'Link', start: s});
389
+ push_stack_entry(state, id, 'Link', s);
390
+ emit_inline_nodes(state, node.children);
391
+ pop_stack_entry(state);
392
+ emit(state, {
393
+ type: 'close',
394
+ id,
395
+ end: offset(state, node.end),
396
+ reference: node.reference,
397
+ link_type: node.link_type,
398
+ });
399
+ break;
400
+ }
401
+ case 'Element':
402
+ case 'Component': {
403
+ const id = alloc_id(state);
404
+ const s = offset(state, node.start);
405
+ emit(state, {type: 'open', id, node_type: node.type, start: s, name: node.name});
406
+ push_stack_entry(state, id, node.type, s, '', node.name);
407
+ emit_inline_nodes(state, node.children);
408
+ pop_stack_entry(state);
409
+ emit(state, {type: 'close', id, end: offset(state, node.end)});
410
+ break;
411
+ }
412
+ // cells are inline-only (lexed via `lex_table_cell`); a block node here
413
+ // would mean a sync/stream grammar drift — fail loud in dev
414
+ default:
415
+ if (DEV) {
416
+ throw new Error(`mdz table cell: unexpected inline node type '${node.type}'`);
417
+ }
418
+ }
419
+ }
420
+ };
@@ -5,30 +5,25 @@
5
5
  */
6
6
 
7
7
  import {
8
- ASTERISK,
9
- BACKTICK,
10
8
  HTTPS_PREFIX_LENGTH,
11
- H_LOWER,
12
- H_UPPER,
13
- LEFT_ANGLE,
14
- LEFT_BRACKET,
15
9
  NEWLINE,
16
- PERIOD,
17
- RIGHT_BRACKET,
18
- SLASH,
19
10
  SPACE,
20
11
  TAB,
21
- TILDE,
22
- UNDERSCORE,
12
+ TEXT_CLASS_NEWLINE,
13
+ TEXT_CLASS_PATH,
14
+ TEXT_CLASS_STOP,
15
+ TEXT_CLASS_URL,
16
+ TEXT_SCAN_CLASS,
23
17
  is_word_char,
24
18
  match_url_prefix_case_insensitive,
25
- } from './mdz_helpers.js';
19
+ } from './mdz_helpers.ts';
26
20
  import {
27
21
  type MdzStreamParserState,
28
22
  accumulate_text,
29
23
  ensure_paragraph,
30
24
  offset,
31
- } from './mdz_stream_parser_state.js';
25
+ } from './mdz_stream_parser_state.ts';
26
+ import {mdz_debug_work} from './mdz_debug_work.ts';
32
27
 
33
28
  /**
34
29
  * Consume a delimiter at the current position as literal text — the shared
@@ -60,35 +55,41 @@ export const consume_text_run = (state: MdzStreamParserState): void => {
60
55
  state.pos++;
61
56
  while (state.pos < state.buffer.length) {
62
57
  const c = state.buffer.charCodeAt(state.pos);
63
- if (
64
- c === BACKTICK ||
65
- c === ASTERISK ||
66
- c === UNDERSCORE ||
67
- c === TILDE ||
68
- c === LEFT_BRACKET ||
69
- c === RIGHT_BRACKET ||
70
- c === LEFT_ANGLE ||
71
- c === NEWLINE ||
72
- // URL/path: only break when actually at a URL or path start.
73
- // Most h/H/./slash in prose are not URLs/paths — continuing the scan
74
- // avoids the full dispatch cycle through process_loop → process_inline.
75
- // For `h`/`H` at a word boundary, also break when there isn't enough
76
- // buffer left to confirm `https://` / `http://` — process_inline's
77
- // speculator then carries the prefix match across chunk boundaries.
78
- // Scheme matching is case-insensitive (RFC 3986).
79
- ((c === H_LOWER || c === H_UPPER) &&
80
- (match_url_prefix_case_insensitive(state.buffer, state.pos) > 0 ||
81
- (state.buffer.length - state.pos < HTTPS_PREFIX_LENGTH &&
82
- !is_word_char(state.buffer.charCodeAt(state.pos - 1))))) ||
83
- ((c === SLASH || c === PERIOD) &&
84
- (state.buffer.charCodeAt(state.pos - 1) === SPACE ||
85
- state.buffer.charCodeAt(state.pos - 1) === NEWLINE ||
86
- state.buffer.charCodeAt(state.pos - 1) === TAB))
87
- ) {
88
- break;
58
+ // one table load classifies the char; zero (the common case) continues
59
+ // the run without any further checks. `TEXT_SCAN_CLASS` is shared with
60
+ // the sync lexer's `#tokenize_text` (`mdz_lexer.ts`); the dispatch bodies
61
+ // intentionally differ — that one handles `\|` escapes and list-run
62
+ // strip, this one URL speculation across chunk boundaries.
63
+ const char_class = c < 128 ? TEXT_SCAN_CLASS[c]! : 0;
64
+ if (char_class === 0) {
65
+ state.pos++;
66
+ continue;
89
67
  }
68
+ if ((char_class & (TEXT_CLASS_STOP | TEXT_CLASS_NEWLINE)) !== 0) break;
69
+ if ((char_class & TEXT_CLASS_URL) !== 0) {
70
+ // URL: only break when actually at a URL start. Most h/H in prose are
71
+ // not URLs — continuing the scan avoids the full dispatch cycle
72
+ // through process_loop → process_inline. At a word boundary, also
73
+ // break when there isn't enough buffer left to confirm `https://` /
74
+ // `http://` — process_inline's speculator then carries the prefix
75
+ // match across chunk boundaries. Scheme matching is case-insensitive
76
+ // (RFC 3986).
77
+ if (
78
+ match_url_prefix_case_insensitive(state.buffer, state.pos) > 0 ||
79
+ (state.buffer.length - state.pos < HTTPS_PREFIX_LENGTH &&
80
+ !is_word_char(state.buffer.charCodeAt(state.pos - 1)))
81
+ ) {
82
+ break;
83
+ }
84
+ } else if ((char_class & TEXT_CLASS_PATH) !== 0) {
85
+ // path (`/` or `.`): only break at a plausible path start
86
+ const prev = state.buffer.charCodeAt(state.pos - 1);
87
+ if (prev === SPACE || prev === NEWLINE || prev === TAB) break;
88
+ }
89
+ // backslash (or a non-starting path char) — ordinary text
90
90
  state.pos++;
91
91
  }
92
+ if (mdz_debug_work.enabled) mdz_debug_work.total += state.pos - start;
92
93
  accumulate_text(state, state.buffer.slice(start, state.pos), offset(state, start));
93
94
  state.prev_char = state.buffer.charCodeAt(state.pos - 1);
94
95
  state.column += state.pos - start;