@fuzdev/mdz 0.2.0 → 0.4.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 (83) hide show
  1. package/README.md +4 -0
  2. package/dist/Mdz.svelte +18 -9
  3. package/dist/Mdz.svelte.d.ts +9 -2
  4. package/dist/Mdz.svelte.d.ts.map +1 -1
  5. package/dist/MdzNodeView.svelte +129 -115
  6. package/dist/MdzNodeView.svelte.d.ts +4 -1
  7. package/dist/MdzNodeView.svelte.d.ts.map +1 -1
  8. package/dist/MdzPrecompiled.svelte +3 -3
  9. package/dist/MdzPrecompiled.svelte.d.ts.map +1 -1
  10. package/dist/MdzRoot.svelte +3 -3
  11. package/dist/MdzRoot.svelte.d.ts.map +1 -1
  12. package/dist/MdzStream.svelte +6 -6
  13. package/dist/MdzStream.svelte.d.ts.map +1 -1
  14. package/dist/MdzStreamNodeView.svelte +129 -118
  15. package/dist/MdzStreamNodeView.svelte.d.ts +4 -1
  16. package/dist/MdzStreamNodeView.svelte.d.ts.map +1 -1
  17. package/dist/context_helpers.js +1 -1
  18. package/dist/mdz.d.ts +6 -0
  19. package/dist/mdz.d.ts.map +1 -1
  20. package/dist/mdz.js +8 -0
  21. package/dist/mdz_contexts.d.ts.map +1 -1
  22. package/dist/mdz_debug_work.d.ts +38 -0
  23. package/dist/mdz_debug_work.d.ts.map +1 -0
  24. package/dist/mdz_debug_work.js +37 -0
  25. package/dist/mdz_helpers.d.ts +61 -0
  26. package/dist/mdz_helpers.d.ts.map +1 -1
  27. package/dist/mdz_helpers.js +74 -2
  28. package/dist/mdz_lexer.d.ts +12 -0
  29. package/dist/mdz_lexer.d.ts.map +1 -1
  30. package/dist/mdz_lexer.js +190 -40
  31. package/dist/mdz_opcodes.d.ts.map +1 -1
  32. package/dist/mdz_opcodes_to_nodes.d.ts.map +1 -1
  33. package/dist/mdz_opcodes_to_nodes.js +22 -17
  34. package/dist/mdz_stream_parser.d.ts.map +1 -1
  35. package/dist/mdz_stream_parser.js +8 -8
  36. package/dist/mdz_stream_parser_block.js +5 -5
  37. package/dist/mdz_stream_parser_inline.d.ts.map +1 -1
  38. package/dist/mdz_stream_parser_inline.js +22 -20
  39. package/dist/mdz_stream_parser_link.d.ts.map +1 -1
  40. package/dist/mdz_stream_parser_link.js +88 -56
  41. package/dist/mdz_stream_parser_list.d.ts.map +1 -1
  42. package/dist/mdz_stream_parser_list.js +23 -9
  43. package/dist/mdz_stream_parser_state.d.ts +29 -0
  44. package/dist/mdz_stream_parser_state.d.ts.map +1 -1
  45. package/dist/mdz_stream_parser_state.js +59 -10
  46. package/dist/mdz_stream_parser_table.d.ts +3 -2
  47. package/dist/mdz_stream_parser_table.d.ts.map +1 -1
  48. package/dist/mdz_stream_parser_table.js +24 -13
  49. package/dist/mdz_stream_parser_text.d.ts.map +1 -1
  50. package/dist/mdz_stream_parser_text.js +36 -25
  51. package/dist/mdz_stream_parser_url.js +7 -7
  52. package/dist/mdz_stream_state.svelte.d.ts.map +1 -1
  53. package/dist/mdz_to_svelte.d.ts +1 -1
  54. package/dist/mdz_to_svelte.d.ts.map +1 -1
  55. package/dist/mdz_to_svelte.js +28 -31
  56. package/dist/mdz_token_parser.d.ts.map +1 -1
  57. package/dist/mdz_token_parser.js +24 -25
  58. package/dist/svelte_preprocess_mdz.d.ts +2 -2
  59. package/dist/svelte_preprocess_mdz.d.ts.map +1 -1
  60. package/dist/svelte_preprocess_mdz.js +41 -47
  61. package/package.json +28 -40
  62. package/src/lib/context_helpers.ts +2 -2
  63. package/src/lib/mdz.ts +11 -2
  64. package/src/lib/mdz_contexts.ts +5 -5
  65. package/src/lib/mdz_debug_work.ts +38 -0
  66. package/src/lib/mdz_helpers.ts +126 -23
  67. package/src/lib/mdz_lexer.ts +243 -93
  68. package/src/lib/mdz_opcodes.ts +1 -1
  69. package/src/lib/mdz_opcodes_to_nodes.ts +35 -30
  70. package/src/lib/mdz_stream_parser.ts +17 -17
  71. package/src/lib/mdz_stream_parser_block.ts +14 -14
  72. package/src/lib/mdz_stream_parser_inline.ts +38 -34
  73. package/src/lib/mdz_stream_parser_link.ts +99 -66
  74. package/src/lib/mdz_stream_parser_list.ts +41 -29
  75. package/src/lib/mdz_stream_parser_state.ts +100 -28
  76. package/src/lib/mdz_stream_parser_table.ts +47 -36
  77. package/src/lib/mdz_stream_parser_text.ts +40 -39
  78. package/src/lib/mdz_stream_parser_url.ts +11 -11
  79. package/src/lib/mdz_stream_state.svelte.ts +8 -8
  80. package/src/lib/mdz_to_svelte.ts +38 -46
  81. package/src/lib/mdz_token_parser.ts +55 -58
  82. package/src/lib/svelte_preprocess_mdz.ts +92 -89
  83. 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.ts';
12
- import {NEWLINE, has_non_whitespace, mdz_heading_id_from_text} from './mdz_helpers.ts';
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.
@@ -112,7 +114,7 @@ export interface MdzBlockquoteState {
112
114
  * offsets — one per quote line; the line's content and terminating
113
115
  * newline are contiguous in both coordinate spaces.
114
116
  */
115
- segments: Array<{inner_start: number; source_start: number}>;
117
+ segments: Array<{ inner_start: number; source_start: number }>;
116
118
  /** Total bytes fed to the inner document so far. */
117
119
  inner_len: number;
118
120
  /**
@@ -214,8 +216,9 @@ export interface MdzStreamParserState {
214
216
  * is `null` for a top-level table (column-0 rows) or the enclosing list item's
215
217
  * marker indent for a table nested as a block child (rows skip the indent and
216
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.
217
220
  */
218
- table: {id: MdzNodeId; item_indent: number | null} | null;
221
+ table: { id: MdzNodeId; item_indent: number | null; cell_parser: MdzTableCellParser } | null;
219
222
  /**
220
223
  * Whether the innermost open ListItem's first inline run is current.
221
224
  * Content then flows directly into the ListItem frame — the sync AST
@@ -243,6 +246,32 @@ export interface MdzStreamParserState {
243
246
  search_memo: Map<string, BufferSearchMemo> | null;
244
247
  /** Open-container counts for `find_open`'s O(1) "nothing open" early exit. */
245
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;
246
275
  /** Whether we're inside a heading (newline ends it). */
247
276
  in_heading: boolean;
248
277
  /** Whether we're inside an optimistic inline Code container. */
@@ -314,7 +343,9 @@ export const create_state = (): MdzStreamParserState => ({
314
343
  last_empty_item_end: 0,
315
344
  base_offset: 0,
316
345
  search_memo: null,
317
- open_counts: {Bold: 0, Italic: 0, Strikethrough: 0, Link: 0},
346
+ open_counts: { Bold: 0, Italic: 0, Strikethrough: 0, Link: 0 },
347
+ open_link_tag_depth: 0,
348
+ open_tag_counts: null,
318
349
  in_heading: false,
319
350
  in_code: false,
320
351
  in_paragraph: false,
@@ -326,7 +357,7 @@ export const create_state = (): MdzStreamParserState => ({
326
357
  paragraph_stack_idx: -1,
327
358
  last_text_id: null,
328
359
  last_text_ended_with_newline: false,
329
- last_text_was_singleton_newline: false,
360
+ last_text_was_singleton_newline: false
330
361
  });
331
362
 
332
363
  /** @nodocs */
@@ -346,7 +377,7 @@ export const push_stack_entry = (
346
377
  node_type: MdzNodeTypeContainer,
347
378
  start: number,
348
379
  delimiter: string = '',
349
- tag_name?: string,
380
+ tag_name?: string
350
381
  ): void => {
351
382
  state.stack.push({
352
383
  id,
@@ -355,13 +386,20 @@ export const push_stack_entry = (
355
386
  tag_name,
356
387
  has_children: false,
357
388
  has_non_whitespace_content: false,
358
- start,
389
+ start
359
390
  });
360
391
  if (node_type === 'Paragraph') {
361
392
  state.paragraph_stack_idx = state.stack.length - 1;
362
393
  } else {
363
394
  bump_open_count(state, node_type, 1);
364
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++;
365
403
  };
366
404
 
367
405
  /**
@@ -374,7 +412,7 @@ export const push_stack_entry = (
374
412
  const bump_open_count = (
375
413
  state: MdzStreamParserState,
376
414
  node_type: MdzNodeTypeContainer,
377
- delta: number,
415
+ delta: number
378
416
  ): void => {
379
417
  const counts = state.open_counts;
380
418
  if (node_type === 'Bold') counts.Bold += delta;
@@ -394,6 +432,16 @@ const bump_open_count = (
394
432
  export const pop_stack_entry = (state: MdzStreamParserState): StackEntry => {
395
433
  const entry = state.stack.pop()!;
396
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--;
397
445
  return entry;
398
446
  };
399
447
 
@@ -454,7 +502,7 @@ const memo_index_of = (
454
502
  state: MdzStreamParserState,
455
503
  memo: BufferSearchMemo,
456
504
  needle: string,
457
- from: number,
505
+ from: number
458
506
  ): number => {
459
507
  const global_from = state.base_offset + from;
460
508
  const global_end = state.base_offset + state.buffer.length;
@@ -465,7 +513,11 @@ const memo_index_of = (
465
513
  // rescan only the appended tail, overlapping the old boundary so a
466
514
  // straddling needle isn't missed
467
515
  const rescan_from = Math.max(global_from, memo.searched_to - (needle.length - 1));
468
- 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
+ }
469
521
  memo.from = global_from;
470
522
  memo.result = result === -1 ? -1 : state.base_offset + result;
471
523
  memo.searched_to = global_end;
@@ -474,6 +526,9 @@ const memo_index_of = (
474
526
  // found result already behind `from` — fall through to a fresh scan
475
527
  }
476
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
+ }
477
532
  memo.from = global_from;
478
533
  memo.result = result === -1 ? -1 : state.base_offset + result;
479
534
  memo.searched_to = global_end;
@@ -493,13 +548,13 @@ const memo_index_of = (
493
548
  export const buffer_index_of = (
494
549
  state: MdzStreamParserState,
495
550
  needle: string,
496
- from: number,
551
+ from: number
497
552
  ): number => {
498
553
  const map = (state.search_memo ??= new Map());
499
554
  let memo = map.get(needle);
500
555
  if (memo === undefined) {
501
556
  // "scanned [0, 0), found nothing" — memo_index_of handles the full scan
502
- memo = {from: 0, result: -1, searched_to: 0};
557
+ memo = { from: 0, result: -1, searched_to: 0 };
503
558
  map.set(needle, memo);
504
559
  }
505
560
  return memo_index_of(state, memo, needle, from);
@@ -519,8 +574,13 @@ export const emit = (state: MdzStreamParserState, op: MdzOpcode): void => {
519
574
  if (top) {
520
575
  top.has_children = true;
521
576
  // propagate non-whitespace marker to the nearest Paragraph so
522
- // whitespace-only paragraphs can be discarded at close
523
- 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
+ ) {
524
584
  mark_paragraph_non_whitespace(state);
525
585
  }
526
586
  }
@@ -582,7 +642,7 @@ export const emit = (state: MdzStreamParserState, op: MdzOpcode): void => {
582
642
  export const accumulate_text = (
583
643
  state: MdzStreamParserState,
584
644
  text: string,
585
- start_offset: number,
645
+ start_offset: number
586
646
  ): void => {
587
647
  if (state.accumulated_text.length === 0) {
588
648
  state.accumulated_text_start = start_offset;
@@ -618,7 +678,7 @@ export const flush_text = (state: MdzStreamParserState): void => {
618
678
  type: 'append_text',
619
679
  id: state.active_text_id,
620
680
  content: state.accumulated_text,
621
- end: state.accumulated_text_end,
681
+ end: state.accumulated_text_end
622
682
  });
623
683
  } else {
624
684
  ensure_paragraph(state);
@@ -630,7 +690,7 @@ export const flush_text = (state: MdzStreamParserState): void => {
630
690
  content: state.accumulated_text,
631
691
  text_type: 'Text',
632
692
  start,
633
- end: state.accumulated_text_end,
693
+ end: state.accumulated_text_end
634
694
  });
635
695
  state.active_text_id = id;
636
696
  }
@@ -649,7 +709,7 @@ export const ensure_paragraph = (state: MdzStreamParserState): void => {
649
709
  if (state.in_heading || state.in_paragraph || state.in_list_item_run) return;
650
710
  const id = alloc_id(state);
651
711
  const start = offset(state);
652
- emit(state, {type: 'open', id, node_type: 'Paragraph', start});
712
+ emit(state, { type: 'open', id, node_type: 'Paragraph', start });
653
713
  push_stack_entry(state, id, 'Paragraph', start);
654
714
  state.in_paragraph = true;
655
715
  };
@@ -709,7 +769,7 @@ export const revert_above = (state: MdzStreamParserState, target_idx: number): v
709
769
  type: 'revert',
710
770
  id: entry.id,
711
771
  replacement_text: entry.delimiter,
712
- start: entry.start,
772
+ start: entry.start
713
773
  });
714
774
  }
715
775
  state.active_text_id = null;
@@ -744,11 +804,23 @@ export const revert_failed_close = (state: MdzStreamParserState, stack_idx: numb
744
804
  const frames_above = state.stack.length - 1 - stack_idx;
745
805
  state.stack.splice(stack_idx, 1);
746
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--;
747
819
  state.opcodes.push({
748
820
  type: 'revert',
749
821
  id: entry.id,
750
822
  replacement_text: entry.delimiter,
751
- start: entry.start,
823
+ start: entry.start
752
824
  });
753
825
  // structural opcode seals the prior text run (mirrors `emit`)
754
826
  seal_text_run(state);
@@ -780,7 +852,7 @@ export const trim_trailing_newline = (state: MdzStreamParserState): void => {
780
852
  }
781
853
  if (state.last_text_id === null || !state.last_text_ended_with_newline) return;
782
854
  // emit trim opcode; consumer adjusts content and removes empty nodes
783
- emit(state, {type: 'trim_text', id: state.last_text_id, count: 1});
855
+ emit(state, { type: 'trim_text', id: state.last_text_id, count: 1 });
784
856
  // if this trim empties a singleton text node, clear active_text_id so
785
857
  // subsequent content doesn't merge into a deleted node via append_text
786
858
  if (state.last_text_was_singleton_newline) {
@@ -807,8 +879,8 @@ export const close_paragraph = (state: MdzStreamParserState): void => {
807
879
  emit(
808
880
  state,
809
881
  discard
810
- ? {type: 'close', id: entry.id, end: offset(state), discard: true}
811
- : {type: 'close', id: entry.id, end: offset(state)},
882
+ ? { type: 'close', id: entry.id, end: offset(state), discard: true }
883
+ : { type: 'close', id: entry.id, end: offset(state) }
812
884
  );
813
885
  state.active_text_id = null;
814
886
  state.in_paragraph = false;
@@ -841,7 +913,7 @@ export const close_heading = (state: MdzStreamParserState): void => {
841
913
  const heading_id = mdz_heading_id_from_text(heading_text);
842
914
  // the heading is now the top of the stack
843
915
  const entry = pop_stack_entry(state);
844
- emit(state, {type: 'close', id: entry.id, end: offset(state), heading_id});
916
+ emit(state, { type: 'close', id: entry.id, end: offset(state), heading_id });
845
917
  state.active_text_id = null;
846
918
  };
847
919
 
@@ -861,7 +933,7 @@ export const close_codeblock_at_eof = (state: MdzStreamParserState): void => {
861
933
  if (state.codeblock.fence_indent === 0) {
862
934
  trim_trailing_newline(state);
863
935
  }
864
- emit(state, {type: 'close', id: state.codeblock.id, end: offset(state)});
936
+ emit(state, { type: 'close', id: state.codeblock.id, end: offset(state) });
865
937
  state.codeblock = null;
866
938
  };
867
939
 
@@ -915,7 +987,7 @@ export const close_list_item = (state: MdzStreamParserState): void => {
915
987
  emit(state, {
916
988
  type: 'close',
917
989
  id: entry.id,
918
- end: entry.has_children ? offset(state) : state.last_empty_item_end,
990
+ end: entry.has_children ? offset(state) : state.last_empty_item_end
919
991
  });
920
992
  state.active_text_id = null;
921
993
  };
@@ -928,7 +1000,7 @@ export const close_list_item = (state: MdzStreamParserState): void => {
928
1000
  export const close_list_level = (state: MdzStreamParserState): void => {
929
1001
  close_list_item(state);
930
1002
  const entry = pop_stack_entry(state);
931
- emit(state, {type: 'close', id: entry.id, end: offset(state)});
1003
+ emit(state, { type: 'close', id: entry.id, end: offset(state) });
932
1004
  state.list_levels.pop();
933
1005
  };
934
1006
 
@@ -7,22 +7,23 @@
7
7
  * whole header emits at once. Body rows then stream one per line. Because a row
8
8
  * is always fully buffered before it emits (no within-row streaming), each
9
9
  * cell's inline content is parsed with the sync reference (`MdzTableCellParser`,
10
- * one `MdzLexer`/`MdzTokenParser` shared across the row's cells) and replayed
11
- * as opcodes — guaranteeing sync↔stream parity by construction.
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.
12
13
  *
13
14
  * @module
14
15
  */
15
16
 
16
- import {DEV} from 'esm-env';
17
+ import { DEV } from 'esm-env';
17
18
 
18
19
  import {
19
20
  NEWLINE,
20
21
  PIPE,
21
22
  is_line_whitespace,
22
23
  mdz_split_table_row,
23
- mdz_parse_table_delimiter,
24
+ mdz_parse_table_delimiter
24
25
  } from './mdz_helpers.ts';
25
- import {MdzTableCellParser, type MdzNode, type MdzTableAlign} from './mdz.ts';
26
+ import { MdzTableCellParser, type MdzNode, type MdzTableAlign } from './mdz.ts';
26
27
  import {
27
28
  type MdzStreamParserState,
28
29
  type TryResult,
@@ -32,7 +33,7 @@ import {
32
33
  flush_text,
33
34
  offset,
34
35
  pop_stack_entry,
35
- push_stack_entry,
36
+ push_stack_entry
36
37
  } from './mdz_stream_parser_state.ts';
37
38
 
38
39
  /**
@@ -70,9 +71,13 @@ export const try_table_start = (state: MdzStreamParserState, forced: boolean): T
70
71
 
71
72
  const table_id = alloc_id(state);
72
73
  const table_start = offset(state, start);
73
- emit(state, {type: 'open', id: table_id, node_type: 'Table', start: table_start, align});
74
+ emit(state, { type: 'open', id: table_id, node_type: 'Table', start: table_start, align });
74
75
  push_stack_entry(state, table_id, 'Table', table_start);
75
- state.table = {id: table_id, item_indent: null};
76
+ state.table = {
77
+ id: table_id,
78
+ item_indent: null,
79
+ cell_parser: new MdzTableCellParser(state.buffer)
80
+ };
76
81
 
77
82
  emit_table_row(state, header_cells, true, start, header_end);
78
83
 
@@ -147,7 +152,7 @@ export const process_table_line = (state: MdzStreamParserState, forced: boolean)
147
152
  const process_table_line_in_item = (
148
153
  state: MdzStreamParserState,
149
154
  forced: boolean,
150
- item_indent: number,
155
+ item_indent: number
151
156
  ): boolean => {
152
157
  if (state.pos >= state.buffer.length) {
153
158
  if (forced) {
@@ -199,9 +204,9 @@ const process_table_line_in_item = (
199
204
  */
200
205
  export const close_table = (state: MdzStreamParserState): void => {
201
206
  if (!state.table) return;
202
- const {item_indent} = state.table;
207
+ const { item_indent } = state.table;
203
208
  const entry = pop_stack_entry(state);
204
- emit(state, {type: 'close', id: entry.id, end: offset(state)});
209
+ emit(state, { type: 'close', id: entry.id, end: offset(state) });
205
210
  state.table = null;
206
211
  state.active_text_id = null;
207
212
  if (item_indent === null) {
@@ -215,7 +220,7 @@ export const close_table = (state: MdzStreamParserState): void => {
215
220
 
216
221
  /** A recognized in-item table head: the header row's cells + the delimiter alignment. */
217
222
  export interface TableInItemHead {
218
- header_cells: Array<{start: number; end: number}>;
223
+ header_cells: Array<{ start: number; end: number }>;
219
224
  header_end: number;
220
225
  align: Array<MdzTableAlign>;
221
226
  delim_end: number;
@@ -234,9 +239,9 @@ export const match_table_in_item_head = (
234
239
  state: MdzStreamParserState,
235
240
  header_first: number,
236
241
  marker_indent: number,
237
- forced: boolean,
242
+ forced: boolean
238
243
  ): TableInItemHead | 'pending' | null => {
239
- const {buffer} = state;
244
+ const { buffer } = state;
240
245
  const header_end = buffer.indexOf('\n', header_first);
241
246
  if (header_end === -1) return forced ? null : 'pending'; // header needs its newline + a delimiter beneath
242
247
  const header_cells = mdz_split_table_row(buffer, header_first, header_end);
@@ -253,7 +258,7 @@ export const match_table_in_item_head = (
253
258
  }
254
259
  const align = mdz_parse_table_delimiter(buffer, dj, delim_end);
255
260
  if (align === null || align.length !== header_cells.length) return null;
256
- return {header_cells, header_end, align, delim_end};
261
+ return { header_cells, header_end, align, delim_end };
257
262
  };
258
263
 
259
264
  /**
@@ -268,7 +273,7 @@ export const open_table_in_item = (
268
273
  state: MdzStreamParserState,
269
274
  header_first: number,
270
275
  marker_indent: number,
271
- head: TableInItemHead,
276
+ head: TableInItemHead
272
277
  ): void => {
273
278
  const table_id = alloc_id(state);
274
279
  const table_start = offset(state, header_first);
@@ -277,10 +282,14 @@ export const open_table_in_item = (
277
282
  id: table_id,
278
283
  node_type: 'Table',
279
284
  start: table_start,
280
- align: head.align,
285
+ align: head.align
281
286
  });
282
287
  push_stack_entry(state, table_id, 'Table', table_start);
283
- state.table = {id: table_id, item_indent: marker_indent};
288
+ state.table = {
289
+ id: table_id,
290
+ item_indent: marker_indent,
291
+ cell_parser: new MdzTableCellParser(state.buffer)
292
+ };
284
293
  emit_table_row(state, head.header_cells, true, header_first, head.header_end);
285
294
  // leave the cursor on the delimiter's newline (or EOF) — the in-item body-row
286
295
  // handler reads from the line after it and hands back on this same convention
@@ -292,23 +301,25 @@ export const open_table_in_item = (
292
301
  /** Emit one table row: open, each cell, close. */
293
302
  const emit_table_row = (
294
303
  state: MdzStreamParserState,
295
- cells: Array<{start: number; end: number}>,
304
+ cells: Array<{ start: number; end: number }>,
296
305
  header: boolean,
297
306
  row_start: number,
298
- row_end: number,
307
+ row_end: number
299
308
  ): void => {
300
309
  const row_id = alloc_id(state);
301
310
  const row_off = offset(state, row_start);
302
- emit(state, {type: 'open', id: row_id, node_type: 'TableRow', start: row_off, header});
311
+ emit(state, { type: 'open', id: row_id, node_type: 'TableRow', start: row_off, header });
303
312
  push_stack_entry(state, row_id, 'TableRow', row_off);
304
- // one lexer/parser shared across the row's cells (all index into the same
305
- // buffer) rather than allocated per cell
306
- const cell_parser = new MdzTableCellParser(state.buffer);
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);
307
318
  for (const cell of cells) {
308
319
  emit_table_cell(state, cell_parser, cell.start, cell.end);
309
320
  }
310
321
  pop_stack_entry(state);
311
- emit(state, {type: 'close', id: row_id, end: offset(state, row_end)});
322
+ emit(state, { type: 'close', id: row_id, end: offset(state, row_end) });
312
323
  state.active_text_id = null;
313
324
  };
314
325
 
@@ -320,15 +331,15 @@ const emit_table_cell = (
320
331
  state: MdzStreamParserState,
321
332
  cell_parser: MdzTableCellParser,
322
333
  cell_start: number,
323
- cell_end: number,
334
+ cell_end: number
324
335
  ): void => {
325
336
  const cell_id = alloc_id(state);
326
337
  const cell_off = offset(state, cell_start);
327
- emit(state, {type: 'open', id: cell_id, node_type: 'TableCell', start: cell_off});
338
+ emit(state, { type: 'open', id: cell_id, node_type: 'TableCell', start: cell_off });
328
339
  push_stack_entry(state, cell_id, 'TableCell', cell_off);
329
340
  emit_inline_nodes(state, cell_parser.parse(cell_start, cell_end));
330
341
  pop_stack_entry(state);
331
- emit(state, {type: 'close', id: cell_id, end: offset(state, cell_end)});
342
+ emit(state, { type: 'close', id: cell_id, end: offset(state, cell_end) });
332
343
  state.active_text_id = null;
333
344
  };
334
345
 
@@ -346,7 +357,7 @@ const emit_inline_nodes = (state: MdzStreamParserState, nodes: Array<MdzNode>):
346
357
  content: node.content,
347
358
  text_type: 'Text',
348
359
  start: offset(state, node.start),
349
- end: offset(state, node.end),
360
+ end: offset(state, node.end)
350
361
  });
351
362
  break;
352
363
  case 'Code':
@@ -356,7 +367,7 @@ const emit_inline_nodes = (state: MdzStreamParserState, nodes: Array<MdzNode>):
356
367
  content: node.content,
357
368
  text_type: 'Code',
358
369
  start: offset(state, node.start),
359
- end: offset(state, node.end),
370
+ end: offset(state, node.end)
360
371
  });
361
372
  break;
362
373
  case 'Bold':
@@ -364,17 +375,17 @@ const emit_inline_nodes = (state: MdzStreamParserState, nodes: Array<MdzNode>):
364
375
  case 'Strikethrough': {
365
376
  const id = alloc_id(state);
366
377
  const s = offset(state, node.start);
367
- emit(state, {type: 'open', id, node_type: node.type, start: s});
378
+ emit(state, { type: 'open', id, node_type: node.type, start: s });
368
379
  push_stack_entry(state, id, node.type, s);
369
380
  emit_inline_nodes(state, node.children);
370
381
  pop_stack_entry(state);
371
- emit(state, {type: 'close', id, end: offset(state, node.end)});
382
+ emit(state, { type: 'close', id, end: offset(state, node.end) });
372
383
  break;
373
384
  }
374
385
  case 'Link': {
375
386
  const id = alloc_id(state);
376
387
  const s = offset(state, node.start);
377
- emit(state, {type: 'open', id, node_type: 'Link', start: s});
388
+ emit(state, { type: 'open', id, node_type: 'Link', start: s });
378
389
  push_stack_entry(state, id, 'Link', s);
379
390
  emit_inline_nodes(state, node.children);
380
391
  pop_stack_entry(state);
@@ -383,7 +394,7 @@ const emit_inline_nodes = (state: MdzStreamParserState, nodes: Array<MdzNode>):
383
394
  id,
384
395
  end: offset(state, node.end),
385
396
  reference: node.reference,
386
- link_type: node.link_type,
397
+ link_type: node.link_type
387
398
  });
388
399
  break;
389
400
  }
@@ -391,11 +402,11 @@ const emit_inline_nodes = (state: MdzStreamParserState, nodes: Array<MdzNode>):
391
402
  case 'Component': {
392
403
  const id = alloc_id(state);
393
404
  const s = offset(state, node.start);
394
- emit(state, {type: 'open', id, node_type: node.type, start: s, name: node.name});
405
+ emit(state, { type: 'open', id, node_type: node.type, start: s, name: node.name });
395
406
  push_stack_entry(state, id, node.type, s, '', node.name);
396
407
  emit_inline_nodes(state, node.children);
397
408
  pop_stack_entry(state);
398
- emit(state, {type: 'close', id, end: offset(state, node.end)});
409
+ emit(state, { type: 'close', id, end: offset(state, node.end) });
399
410
  break;
400
411
  }
401
412
  // cells are inline-only (lexed via `lex_table_cell`); a block node here
@@ -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
- match_url_prefix_case_insensitive,
18
+ match_url_prefix_case_insensitive
25
19
  } from './mdz_helpers.ts';
26
20
  import {
27
21
  type MdzStreamParserState,
28
22
  accumulate_text,
29
23
  ensure_paragraph,
30
- offset,
24
+ offset
31
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;