@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
@@ -38,6 +38,7 @@ import {
38
38
  HR_HYPHEN_COUNT,
39
39
  MIN_CODEBLOCK_BACKTICKS,
40
40
  MAX_HEADING_LEVEL,
41
+ MAX_INLINE_NESTING_DEPTH,
41
42
  MAX_LIST_NUMBER_DIGITS,
42
43
  match_url_prefix_case_insensitive,
43
44
  is_at_absolute_path,
@@ -50,10 +51,15 @@ import {
50
51
  mdz_split_table_row,
51
52
  mdz_parse_table_delimiter,
52
53
  PIPE,
53
- BACKSLASH,
54
+ TEXT_CLASS_BACKSLASH,
55
+ TEXT_CLASS_NEWLINE,
56
+ TEXT_CLASS_STOP,
57
+ TEXT_CLASS_URL,
58
+ TEXT_SCAN_CLASS
54
59
  } from './mdz_helpers.ts';
55
60
 
56
- import type {MdzTableAlign} from './mdz.ts';
61
+ import type { MdzTableAlign } from './mdz.ts';
62
+ import { mdz_debug_work } from './mdz_debug_work.ts';
57
63
 
58
64
  //
59
65
  // Token types
@@ -289,8 +295,8 @@ export class MdzLexer {
289
295
  #index: number = 0;
290
296
  #tokens: Array<MdzToken> = [];
291
297
  #max_search_index: number = Number.MAX_SAFE_INTEGER;
292
- #search_memo: Map<string, {from: number; result: number}> = new Map();
293
- #break_memo: {from: number; result: number} | null = null;
298
+ #search_memo: Map<string, { from: number; result: number }> = new Map();
299
+ #break_memo: { from: number; result: number } | null = null;
294
300
  /**
295
301
  * Whether `#tokenize_text` is inside a list-item inline run: single
296
302
  * newlines are soft breaks whose following structural indent strips from
@@ -304,11 +310,69 @@ export class MdzLexer {
304
310
  * and `\|` unescapes to a literal pipe.
305
311
  */
306
312
  #in_table_cell: boolean = false;
313
+ /**
314
+ * Start offsets of markdown-link opens (`[`) that definitively failed — so
315
+ * a re-lex from before the `[` short-circuits to literal text instead of
316
+ * re-attempting (and re-descending into) the same failing link. Without it,
317
+ * the revert-and-re-tokenize failure path is exponential on nested `[`
318
+ * (a ~30-byte `[`×n input hangs). A link's outcome (does a valid `](ref)`
319
+ * follow?) is independent of `#max_search_index`, so `start` alone keys it.
320
+ * The outcome is a pure function of the immutable text, so the memo is valid
321
+ * for the whole lex and across table-cell ranges; cleared on `rebind`.
322
+ */
323
+ #failed_link_starts: Set<number> = new Set();
324
+ /**
325
+ * The tag analogue of `#failed_link_starts`, keyed `${start}:${bound}`. A
326
+ * tag's closer search respects `#max_search_index`, so the same `start` can
327
+ * fail under a narrow bound yet succeed under the wider one a re-lex uses —
328
+ * the bound is part of the key.
329
+ */
330
+ #failed_tag: Set<string> = new Set();
331
+ /**
332
+ * Current link/tag nesting depth — the number of open
333
+ * `link_text_open`/`tag_open` containers being recursed into. Bumped around
334
+ * `#tokenize_markdown_link`/`#tokenize_tag`'s children loops (not by
335
+ * delimiters, which can't nest deeply — see `MAX_INLINE_NESTING_DEPTH`). At
336
+ * the cap a further `[`/`<` renders literal instead of opening, bounding
337
+ * recursion depth (no stack overflow) and, with the failure memos, the
338
+ * revert re-scan (linear, not quadratic) on adversarial nesting.
339
+ */
340
+ #inline_depth: number = 0;
341
+ /**
342
+ * Start offsets of `[`/`<` openers suppressed by the nesting-depth cap
343
+ * (`#inline_depth >= MAX_INLINE_NESTING_DEPTH`) — position-keyed like
344
+ * `#failed_link_starts` so a re-lex from a shallower depth short-circuits to
345
+ * literal text rather than re-opening the construct (which would reintroduce
346
+ * the quadratic the cap removes). A suppression is bound-independent, so
347
+ * `start` alone keys it; cleared on `rebind`.
348
+ */
349
+ #depth_capped: Set<number> = new Set();
307
350
 
308
351
  constructor(text: string) {
309
352
  this.#text = text;
310
353
  }
311
354
 
355
+ /**
356
+ * Rebind to a new text, so one lexer instance can be reused across inputs —
357
+ * the streaming table path reuses one across a table's rows via
358
+ * `MdzTableCellParser` instead of allocating a lexer (and its memo `Map`)
359
+ * per row. The search memos survive when the text is identical (they're
360
+ * valid over the immutable text — the common case for rows parsed from one
361
+ * buffered chunk) and clear otherwise. Per-parse cursor state is reset by
362
+ * `lex_table_cell` itself.
363
+ *
364
+ * @nodocs
365
+ */
366
+ rebind(text: string): void {
367
+ if (this.#text === text) return;
368
+ this.#text = text;
369
+ this.#search_memo.clear();
370
+ this.#break_memo = null;
371
+ this.#failed_link_starts.clear();
372
+ this.#failed_tag.clear();
373
+ this.#depth_capped.clear();
374
+ }
375
+
312
376
  /**
313
377
  * Memoized `String#indexOf` over the immutable text.
314
378
  *
@@ -325,8 +389,12 @@ export class MdzLexer {
325
389
  return memo.result;
326
390
  }
327
391
  const result = this.#text.indexOf(needle, from);
392
+ if (mdz_debug_work.enabled) {
393
+ // scan span: `indexOf` examined [from, result] (found) or [from, EOF)
394
+ mdz_debug_work.total += (result === -1 ? this.#text.length : result) - from;
395
+ }
328
396
  if (memo === undefined) {
329
- this.#search_memo.set(needle, {from, result});
397
+ this.#search_memo.set(needle, { from, result });
330
398
  } else {
331
399
  memo.from = from;
332
400
  memo.result = result;
@@ -353,7 +421,7 @@ export class MdzLexer {
353
421
  if (this.#is_at_paragraph_break()) {
354
422
  const start = this.#index;
355
423
  this.#skip_blank_lines();
356
- this.#tokens.push({type: 'paragraph_break', start, end: this.#index});
424
+ this.#tokens.push({ type: 'paragraph_break', start, end: this.#index });
357
425
  continue;
358
426
  }
359
427
 
@@ -376,6 +444,9 @@ export class MdzLexer {
376
444
  */
377
445
  lex_table_cell(cell_start: number, cell_end: number): Array<MdzToken> {
378
446
  this.#tokens = [];
447
+ // depth is balanced back to 0 per cell, but pin it so a future early-return
448
+ // between an `#inline_depth++` and its decrement can't leak into the next cell
449
+ this.#inline_depth = 0;
379
450
  this.#tokenize_cell_inline(cell_start, cell_end);
380
451
  return this.#tokens;
381
452
  }
@@ -469,7 +540,7 @@ export class MdzLexer {
469
540
  type: 'heading_start',
470
541
  level: hash_count as 1 | 2 | 3 | 4 | 5 | 6,
471
542
  start,
472
- end: this.#index, // end of "## " prefix
543
+ end: this.#index // end of "## " prefix
473
544
  });
474
545
 
475
546
  // Find end-of-line to bound nested tokenizers (prevents tag scanner from scanning past heading)
@@ -509,7 +580,7 @@ export class MdzLexer {
509
580
  this.#max_search_index = saved_max;
510
581
 
511
582
  // Emit heading_end marker so the token parser knows where heading content stops
512
- this.#tokens.push({type: 'heading_end', start: this.#index, end: this.#index});
583
+ this.#tokens.push({ type: 'heading_end', start: this.#index, end: this.#index });
513
584
 
514
585
  // Skip newlines after heading
515
586
  this.#skip_blank_lines();
@@ -542,7 +613,7 @@ export class MdzLexer {
542
613
  const start = this.#index;
543
614
  this.#index = i; // consume up to but not including newline
544
615
 
545
- this.#tokens.push({type: 'hr', start, end: this.#index});
616
+ this.#tokens.push({ type: 'hr', start, end: this.#index });
546
617
 
547
618
  // Skip newlines after HR
548
619
  this.#skip_blank_lines();
@@ -570,7 +641,7 @@ export class MdzLexer {
570
641
  * Match a quote prefix in the complete sync text — never `'pending'`.
571
642
  * See `mdz_match_blockquote_prefix`.
572
643
  */
573
- #match_blockquote_prefix(i: number): {depth: number; content_start: number} | null {
644
+ #match_blockquote_prefix(i: number): { depth: number; content_start: number } | null {
574
645
  const prefix = mdz_match_blockquote_prefix(this.#text, i, true);
575
646
  return prefix === 'pending' ? null : prefix;
576
647
  }
@@ -623,7 +694,7 @@ export class MdzLexer {
623
694
  // quote's content parses exactly like a standalone document (a complete
624
695
  // fence opener line on the last quote line opens an empty fence, not text)
625
696
  const parts: Array<string> = [];
626
- const segments: Array<{inner_start: number; source_start: number}> = [];
697
+ const segments: Array<{ inner_start: number; source_start: number }> = [];
627
698
  let inner_len = 0;
628
699
  let line_first = first;
629
700
  let last_line_end = first;
@@ -631,7 +702,7 @@ export class MdzLexer {
631
702
  const strip = line_first + mdz_blockquote_strip_width(this.#text, line_first);
632
703
  let nl = this.#text.indexOf('\n', line_first);
633
704
  if (nl === -1) nl = this.#text.length;
634
- segments.push({inner_start: inner_len, source_start: strip});
705
+ segments.push({ inner_start: inner_len, source_start: strip });
635
706
  const content = this.#text.slice(Math.min(strip, nl), nl < this.#text.length ? nl + 1 : nl);
636
707
  parts.push(content);
637
708
  inner_len += content.length;
@@ -656,14 +727,14 @@ export class MdzLexer {
656
727
  line_first = next_first;
657
728
  }
658
729
 
659
- this.#tokens.push({type: 'blockquote_open', start: first, end: first});
730
+ this.#tokens.push({ type: 'blockquote_open', start: first, end: first });
660
731
  const inner_tokens = new MdzLexer(parts.join('')).tokenize();
661
732
  for (const token of inner_tokens) {
662
733
  token.start = mdz_remap_segments(segments, token.start);
663
734
  token.end = mdz_remap_segments(segments, token.end);
664
735
  this.#tokens.push(token);
665
736
  }
666
- this.#tokens.push({type: 'blockquote_close', start: last_line_end, end: last_line_end});
737
+ this.#tokens.push({ type: 'blockquote_close', start: last_line_end, end: last_line_end });
667
738
  this.#index = last_line_end;
668
739
  }
669
740
 
@@ -692,7 +763,7 @@ export class MdzLexer {
692
763
  if (align === null || align.length !== header_cells.length) return false;
693
764
 
694
765
  // committed — emit the table; the delimiter row is structural (not emitted)
695
- this.#tokens.push({type: 'table_open', align, start: table_start, end: header_end});
766
+ this.#tokens.push({ type: 'table_open', align, start: table_start, end: header_end });
696
767
  this.#emit_table_row(header_cells, true, table_start, header_end);
697
768
 
698
769
  this.#index = delim_end;
@@ -710,23 +781,23 @@ export class MdzLexer {
710
781
  table_end = row_end;
711
782
  }
712
783
 
713
- this.#tokens.push({type: 'table_close', start: table_end, end: table_end});
784
+ this.#tokens.push({ type: 'table_close', start: table_end, end: table_end });
714
785
  this.#skip_blank_lines();
715
786
  return true;
716
787
  }
717
788
 
718
789
  /** Emit one table row's open / cell / close tokens. */
719
790
  #emit_table_row(
720
- cells: Array<{start: number; end: number}>,
791
+ cells: Array<{ start: number; end: number }>,
721
792
  header: boolean,
722
793
  row_start: number,
723
- row_end: number,
794
+ row_end: number
724
795
  ): void {
725
- this.#tokens.push({type: 'table_row_open', header, start: row_start, end: row_start});
796
+ this.#tokens.push({ type: 'table_row_open', header, start: row_start, end: row_start });
726
797
  for (const cell of cells) {
727
798
  this.#emit_table_cell(cell.start, cell.end);
728
799
  }
729
- this.#tokens.push({type: 'table_row_close', start: row_end, end: row_end});
800
+ this.#tokens.push({ type: 'table_row_close', start: row_end, end: row_end });
730
801
  }
731
802
 
732
803
  /**
@@ -736,9 +807,9 @@ export class MdzLexer {
736
807
  * stops at that bound and unescapes `\|`.
737
808
  */
738
809
  #emit_table_cell(cell_start: number, cell_end: number): void {
739
- this.#tokens.push({type: 'table_cell_open', start: cell_start, end: cell_start});
810
+ this.#tokens.push({ type: 'table_cell_open', start: cell_start, end: cell_start });
740
811
  this.#tokenize_cell_inline(cell_start, cell_end);
741
- this.#tokens.push({type: 'table_cell_close', start: cell_end, end: cell_end});
812
+ this.#tokens.push({ type: 'table_cell_close', start: cell_end, end: cell_end });
742
813
  }
743
814
 
744
815
  /**
@@ -767,9 +838,9 @@ export class MdzLexer {
767
838
  */
768
839
  #match_item_table_head(
769
840
  first: number,
770
- marker_indent: number,
841
+ marker_indent: number
771
842
  ): {
772
- header_cells: Array<{start: number; end: number}>;
843
+ header_cells: Array<{ start: number; end: number }>;
773
844
  header_end: number;
774
845
  align: Array<MdzTableAlign>;
775
846
  delim_end: number;
@@ -786,7 +857,7 @@ export class MdzLexer {
786
857
  if (delim_end === -1) delim_end = this.#text.length;
787
858
  const align = mdz_parse_table_delimiter(this.#text, dj, delim_end);
788
859
  if (align === null || align.length !== header_cells.length) return null;
789
- return {header_cells, header_end, align, delim_end};
860
+ return { header_cells, header_end, align, delim_end };
790
861
  }
791
862
 
792
863
  /** Whether a table opens at `first` as a block child of an item at `marker_indent`. */
@@ -803,7 +874,12 @@ export class MdzLexer {
803
874
  */
804
875
  #tokenize_item_table(first: number, marker_indent: number): void {
805
876
  const head = this.#match_item_table_head(first, marker_indent)!;
806
- this.#tokens.push({type: 'table_open', align: head.align, start: first, end: head.header_end});
877
+ this.#tokens.push({
878
+ type: 'table_open',
879
+ align: head.align,
880
+ start: first,
881
+ end: head.header_end
882
+ });
807
883
  this.#emit_table_row(head.header_cells, true, first, head.header_end);
808
884
  this.#index = head.delim_end;
809
885
  let table_end = head.delim_end;
@@ -821,7 +897,7 @@ export class MdzLexer {
821
897
  this.#index = row_end;
822
898
  table_end = row_end;
823
899
  }
824
- this.#tokens.push({type: 'table_close', start: table_end, end: table_end});
900
+ this.#tokens.push({ type: 'table_close', start: table_end, end: table_end });
825
901
  }
826
902
 
827
903
  // -- List tokenizers --
@@ -893,7 +969,7 @@ export class MdzLexer {
893
969
  }
894
970
  if (levels[levels.length - 1]!.ordered === marker.ordered) {
895
971
  // sibling item
896
- this.#tokens.push({type: 'list_item_close', start: first, end: first});
972
+ this.#tokens.push({ type: 'list_item_close', start: first, end: first });
897
973
  this.#list_item_open(marker);
898
974
  } else {
899
975
  // marker-type switch: close the list, open a new one at this indent
@@ -909,13 +985,13 @@ export class MdzLexer {
909
985
  // empty item: only at an indent exactly matching an open level of
910
986
  // the same marker type — anything else degrades below
911
987
  const level_idx = levels.findIndex(
912
- (l) => l.indent === indent && l.ordered === marker.ordered,
988
+ (l) => l.indent === indent && l.ordered === marker.ordered
913
989
  );
914
990
  if (level_idx !== -1) {
915
991
  while (levels.length - 1 > level_idx) {
916
992
  this.#list_close(levels);
917
993
  }
918
- this.#tokens.push({type: 'list_item_close', start: first, end: first});
994
+ this.#tokens.push({ type: 'list_item_close', start: first, end: first });
919
995
  this.#list_item_open(marker);
920
996
  // consume the marker and any trailing whitespace, leaving #index
921
997
  // at the line's newline (or EOF)
@@ -985,7 +1061,7 @@ export class MdzLexer {
985
1061
  while (levels.length - 1 > target_idx) {
986
1062
  this.#list_close(levels);
987
1063
  }
988
- this.#tokens.push({type: 'paragraph_break', start: line_start, end: line_start});
1064
+ this.#tokens.push({ type: 'paragraph_break', start: line_start, end: line_start });
989
1065
  this.#list_run(levels, first);
990
1066
  continue;
991
1067
  }
@@ -1020,13 +1096,13 @@ export class MdzLexer {
1020
1096
  start: i,
1021
1097
  marker_end: after,
1022
1098
  content_start: after,
1023
- empty: true,
1099
+ empty: true
1024
1100
  };
1025
1101
  }
1026
1102
  if (this.#text.charCodeAt(after) !== SPACE) return null;
1027
1103
  const content_start = after + 1;
1028
1104
  const empty = this.#rest_is_blank(content_start);
1029
- return {ordered: false, number: 0, start: i, marker_end: after, content_start, empty};
1105
+ return { ordered: false, number: 0, start: i, marker_end: after, content_start, empty };
1030
1106
  }
1031
1107
  if (c >= ZERO && c <= NINE) {
1032
1108
  let j = i + 1;
@@ -1048,13 +1124,13 @@ export class MdzLexer {
1048
1124
  start: i,
1049
1125
  marker_end: after,
1050
1126
  content_start: after,
1051
- empty: true,
1127
+ empty: true
1052
1128
  };
1053
1129
  }
1054
1130
  if (this.#text.charCodeAt(after) !== SPACE) return null;
1055
1131
  const content_start = after + 1;
1056
1132
  const empty = this.#rest_is_blank(content_start);
1057
- return {ordered: true, number, start: i, marker_end: after, content_start, empty};
1133
+ return { ordered: true, number, start: i, marker_end: after, content_start, empty };
1058
1134
  }
1059
1135
  return null;
1060
1136
  }
@@ -1091,26 +1167,31 @@ export class MdzLexer {
1091
1167
  type: 'list_open',
1092
1168
  ordered: marker.ordered,
1093
1169
  start: marker.start,
1094
- end: marker.start,
1170
+ end: marker.start
1095
1171
  });
1096
1172
  this.#list_item_open(marker);
1097
- levels.push({indent, ordered: marker.ordered});
1173
+ levels.push({ indent, ordered: marker.ordered });
1098
1174
  }
1099
1175
 
1100
1176
  /** Emit a `list_item_open` token for `marker`. */
1101
1177
  #list_item_open(marker: MdzListMarker): void {
1102
1178
  const end = marker.empty ? marker.marker_end : marker.content_start;
1103
1179
  if (marker.ordered) {
1104
- this.#tokens.push({type: 'list_item_open', number: marker.number, start: marker.start, end});
1180
+ this.#tokens.push({
1181
+ type: 'list_item_open',
1182
+ number: marker.number,
1183
+ start: marker.start,
1184
+ end
1185
+ });
1105
1186
  } else {
1106
- this.#tokens.push({type: 'list_item_open', start: marker.start, end});
1187
+ this.#tokens.push({ type: 'list_item_open', start: marker.start, end });
1107
1188
  }
1108
1189
  }
1109
1190
 
1110
1191
  /** Close the deepest open level: its current item, then its list. */
1111
1192
  #list_close(levels: Array<MdzListLevel>): void {
1112
- this.#tokens.push({type: 'list_item_close', start: this.#index, end: this.#index});
1113
- this.#tokens.push({type: 'list_close', start: this.#index, end: this.#index});
1193
+ this.#tokens.push({ type: 'list_item_close', start: this.#index, end: this.#index });
1194
+ this.#tokens.push({ type: 'list_close', start: this.#index, end: this.#index });
1114
1195
  levels.pop();
1115
1196
  }
1116
1197
 
@@ -1207,8 +1288,8 @@ export class MdzLexer {
1207
1288
  */
1208
1289
  #match_fence(
1209
1290
  first: number,
1210
- fence_indent: number,
1211
- ): {token: MdzTokenCodeblock; end: number} | null {
1291
+ fence_indent: number
1292
+ ): { token: MdzTokenCodeblock; end: number } | null {
1212
1293
  let i = first;
1213
1294
  let backtick_count = 0;
1214
1295
  while (i < this.#text.length && this.#text.charCodeAt(i) === BACKTICK) {
@@ -1265,8 +1346,8 @@ export class MdzLexer {
1265
1346
  ? this.#text.slice(content_start, Math.max(content_start, line_start - 1))
1266
1347
  : content_lines.join('\n');
1267
1348
  return {
1268
- token: {type: 'codeblock', lang, content, start: first, end: m},
1269
- end: m,
1349
+ token: { type: 'codeblock', lang, content, start: first, end: m },
1350
+ end: m
1270
1351
  };
1271
1352
  }
1272
1353
  }
@@ -1276,7 +1357,7 @@ export class MdzLexer {
1276
1357
  if (fence_indent > 0) {
1277
1358
  const line_end = nl === -1 ? this.#text.length : nl;
1278
1359
  content_lines.push(
1279
- this.#text.slice(line_start + Math.min(fence_indent, line_indent), line_end),
1360
+ this.#text.slice(line_start + Math.min(fence_indent, line_indent), line_end)
1280
1361
  );
1281
1362
  }
1282
1363
  if (nl === -1) break;
@@ -1287,8 +1368,8 @@ export class MdzLexer {
1287
1368
  let content = fence_indent === 0 ? this.#text.slice(content_start) : content_lines.join('\n');
1288
1369
  if (content.endsWith('\n')) content = content.slice(0, -1);
1289
1370
  return {
1290
- token: {type: 'codeblock', lang, content, start: first, end: this.#text.length},
1291
- end: this.#text.length,
1371
+ token: { type: 'codeblock', lang, content, start: first, end: this.#text.length },
1372
+ end: this.#text.length
1292
1373
  };
1293
1374
  }
1294
1375
 
@@ -1382,7 +1463,7 @@ export class MdzLexer {
1382
1463
  }
1383
1464
 
1384
1465
  this.#index = content_end + 1;
1385
- this.#tokens.push({type: 'code', content, start, end: this.#index});
1466
+ this.#tokens.push({ type: 'code', content, start, end: this.#index });
1386
1467
  }
1387
1468
 
1388
1469
  #tokenize_double_delimiter(delimiter: '**' | '~~', kind: 'bold' | 'strikethrough'): void {
@@ -1421,7 +1502,7 @@ export class MdzLexer {
1421
1502
  const open_type = kind === 'bold' ? 'bold_open' : 'strikethrough_open';
1422
1503
  const close_type = kind === 'bold' ? 'bold_close' : 'strikethrough_close';
1423
1504
  const open_token_index = this.#tokens.length;
1424
- this.#tokens.push({type: open_type, start, end: this.#index});
1505
+ this.#tokens.push({ type: open_type, start, end: this.#index });
1425
1506
 
1426
1507
  // Set search boundary for nested parsers
1427
1508
  const saved_max = this.#max_search_index;
@@ -1448,11 +1529,11 @@ export class MdzLexer {
1448
1529
  return;
1449
1530
  }
1450
1531
 
1451
- this.#tokens.push({type: close_type, start: close_index, end: close_index + 2});
1532
+ this.#tokens.push({ type: close_type, start: close_index, end: close_index + 2 });
1452
1533
  this.#index = close_index + 2;
1453
1534
  } else {
1454
1535
  // Didn't reach closing - convert opening to text
1455
- this.#tokens[open_token_index] = {type: 'text', content: delimiter, start, end: start + 2};
1536
+ this.#tokens[open_token_index] = { type: 'text', content: delimiter, start, end: start + 2 };
1456
1537
  }
1457
1538
  }
1458
1539
 
@@ -1495,7 +1576,7 @@ export class MdzLexer {
1495
1576
  // Emit open token
1496
1577
  this.#index++;
1497
1578
  const open_token_index = this.#tokens.length;
1498
- this.#tokens.push({type: 'italic_open', start, end: this.#index});
1579
+ this.#tokens.push({ type: 'italic_open', start, end: this.#index });
1499
1580
 
1500
1581
  // Set search boundary for nested parsers
1501
1582
  const saved_max = this.#max_search_index;
@@ -1525,7 +1606,7 @@ export class MdzLexer {
1525
1606
  this.#tokens.push({
1526
1607
  type: 'italic_close',
1527
1608
  start: close_index,
1528
- end: close_index + 1,
1609
+ end: close_index + 1
1529
1610
  });
1530
1611
  this.#index = close_index + 1;
1531
1612
  } else {
@@ -1534,26 +1615,45 @@ export class MdzLexer {
1534
1615
  type: 'text',
1535
1616
  content: '_',
1536
1617
  start,
1537
- end: start + 1,
1618
+ end: start + 1
1538
1619
  };
1539
1620
  }
1540
1621
  }
1541
1622
 
1542
1623
  #tokenize_markdown_link(): void {
1543
1624
  const start = this.#index;
1625
+ // a link open at this offset already failed, or was suppressed by the
1626
+ // nesting cap — emit `[` as text instead of re-descending into the same
1627
+ // failing children (exponential on nested `[`); the short-circuit
1628
+ // reproduces the revert's exact net effect (`[` text, cursor at start + 1)
1629
+ if (this.#failed_link_starts.has(start) || this.#depth_capped.has(start)) {
1630
+ this.#index = start + 1;
1631
+ this.#emit_text('[', start);
1632
+ return;
1633
+ }
1634
+ // nesting-depth cap: too deep to open — render literal and record the
1635
+ // suppression so a shallower re-lex short-circuits here too
1636
+ if (this.#inline_depth >= MAX_INLINE_NESTING_DEPTH) {
1637
+ this.#depth_capped.add(start);
1638
+ this.#index = start + 1;
1639
+ this.#emit_text('[', start);
1640
+ return;
1641
+ }
1544
1642
  this.#index++; // consume [ (dispatch guarantees it)
1545
1643
 
1546
1644
  // Emit link_text_open
1547
- this.#tokens.push({type: 'link_text_open', start, end: this.#index});
1645
+ this.#tokens.push({ type: 'link_text_open', start, end: this.#index });
1548
1646
 
1549
1647
  // Tokenize children until ] — only `]` delimits link text; a bare `)`
1550
1648
  // is ordinary content (matching the streaming parser, which has no `)`
1551
1649
  // dispatch case — the reference scan finds its own `)` after `](`)
1650
+ this.#inline_depth++;
1552
1651
  while (this.#index < this.#text.length) {
1553
1652
  if (this.#text.charCodeAt(this.#index) === RIGHT_BRACKET) break;
1554
1653
  if (this.#is_at_paragraph_break()) break;
1555
1654
  this.#tokenize_inline();
1556
1655
  }
1656
+ this.#inline_depth--;
1557
1657
 
1558
1658
  // Check for ]
1559
1659
  if (this.#index >= this.#text.length || this.#text.charCodeAt(this.#index) !== RIGHT_BRACKET) {
@@ -1567,7 +1667,7 @@ export class MdzLexer {
1567
1667
  this.#tokens.push({
1568
1668
  type: 'link_text_close',
1569
1669
  start: bracket_close_start,
1570
- end: this.#index,
1670
+ end: this.#index
1571
1671
  });
1572
1672
 
1573
1673
  // Check for (
@@ -1617,11 +1717,18 @@ export class MdzLexer {
1617
1717
  reference,
1618
1718
  link_type,
1619
1719
  start: bracket_close_start + 1, // after ]
1620
- end: this.#index,
1720
+ end: this.#index
1621
1721
  });
1622
1722
  }
1623
1723
 
1624
1724
  #revert_tokens_from_link_open(start: number): void {
1725
+ // record the failure so a re-lex from before this `[` short-circuits.
1726
+ // Keyed by `start` alone — unlike `#failed_tag`'s `start:bound` — because a
1727
+ // link's outcome is bound-independent: the `]`-terminator scan (the children
1728
+ // loop) runs to `text.length` and never consults `#max_search_index`, so a
1729
+ // wider-bound re-lex can't move it. The bound reaches nested tags inside the
1730
+ // link text, but re-attempting those wider never flips the link's net result.
1731
+ this.#failed_link_starts.add(start);
1625
1732
  // Find and remove link_text_open and all tokens after it
1626
1733
  let open_idx = -1;
1627
1734
  for (let i = this.#tokens.length - 1; i >= 0; i--) {
@@ -1639,6 +1746,32 @@ export class MdzLexer {
1639
1746
 
1640
1747
  #tokenize_tag(): void {
1641
1748
  const start = this.#index;
1749
+ // short-circuit a re-attempt of a tag open that already failed at this
1750
+ // offset+bound — re-descending into the same failing children is
1751
+ // exponential on nested same-name tags. Keyed by
1752
+ // `#max_search_index` (captured as `bound`) because the closer search is
1753
+ // bound-sensitive, so a wider-bound re-lex can legitimately succeed. The
1754
+ // key string is built only when the memo is non-empty (adversarial
1755
+ // input); prose never allocates it. The short-circuit reproduces a
1756
+ // revert's exact net effect (`<` text, cursor at start + 1).
1757
+ const bound = this.#max_search_index;
1758
+ if (
1759
+ this.#depth_capped.has(start) ||
1760
+ (this.#failed_tag.size !== 0 && this.#failed_tag.has(`${start}:${bound}`))
1761
+ ) {
1762
+ this.#index = start + 1;
1763
+ this.#emit_text('<', start);
1764
+ return;
1765
+ }
1766
+ // nesting-depth cap: too deep to open a tag — render literal and record
1767
+ // the suppression (position-keyed) so a shallower re-lex short-circuits
1768
+ // here too, keeping the revert re-scan linear
1769
+ if (this.#inline_depth >= MAX_INLINE_NESTING_DEPTH) {
1770
+ this.#depth_capped.add(start);
1771
+ this.#index = start + 1;
1772
+ this.#emit_text('<', start);
1773
+ return;
1774
+ }
1642
1775
  this.#index++; // consume <
1643
1776
 
1644
1777
  // Tag name must start with a letter
@@ -1678,13 +1811,14 @@ export class MdzLexer {
1678
1811
  name: tag_name,
1679
1812
  is_component,
1680
1813
  start,
1681
- end: this.#index,
1814
+ end: this.#index
1682
1815
  });
1683
1816
  return;
1684
1817
  }
1685
1818
 
1686
1819
  // Check for >
1687
1820
  if (this.#index >= this.#text.length || this.#text.charCodeAt(this.#index) !== RIGHT_ANGLE) {
1821
+ this.#failed_tag.add(`${start}:${bound}`);
1688
1822
  this.#index = start + 1;
1689
1823
  this.#emit_text('<', start);
1690
1824
  return;
@@ -1703,11 +1837,13 @@ export class MdzLexer {
1703
1837
  const search_limit = Math.min(this.#max_search_index, this.#text.length);
1704
1838
  const closing_tag_pos = this.#index_of(closing_tag, this.#index);
1705
1839
  if (closing_tag_pos === -1 || closing_tag_pos > search_limit) {
1840
+ this.#failed_tag.add(`${start}:${bound}`);
1706
1841
  this.#index = start + 1;
1707
1842
  this.#emit_text('<', start);
1708
1843
  return;
1709
1844
  }
1710
1845
  if (this.#has_paragraph_break_between(this.#index, closing_tag_pos)) {
1846
+ this.#failed_tag.add(`${start}:${bound}`);
1711
1847
  this.#index = start + 1;
1712
1848
  this.#emit_text('<', start);
1713
1849
  return;
@@ -1715,7 +1851,7 @@ export class MdzLexer {
1715
1851
 
1716
1852
  // Emit tag_open
1717
1853
  const open_token_index = this.#tokens.length;
1718
- this.#tokens.push({type: 'tag_open', name: tag_name, is_component, start, end: this.#index});
1854
+ this.#tokens.push({ type: 'tag_open', name: tag_name, is_component, start, end: this.#index });
1719
1855
 
1720
1856
  // Tokenize children until the closing tag. Each iteration narrows
1721
1857
  // `#max_search_index` to the next closer so a child's delimiter scan
@@ -1723,8 +1859,10 @@ export class MdzLexer {
1723
1859
  // the code span). Recomputed per iteration because a nested same-name
1724
1860
  // tag legitimately consumes the nearest closer (`<b>x<b>y</b></b>`).
1725
1861
  const saved_max = this.#max_search_index;
1862
+ this.#inline_depth++;
1726
1863
  while (this.#index < this.#text.length) {
1727
1864
  if (this.#match(closing_tag)) {
1865
+ this.#inline_depth--;
1728
1866
  this.#max_search_index = saved_max;
1729
1867
  const close_start = this.#index;
1730
1868
  this.#index += closing_tag.length;
@@ -1732,7 +1870,7 @@ export class MdzLexer {
1732
1870
  type: 'tag_close',
1733
1871
  name: tag_name,
1734
1872
  start: close_start,
1735
- end: this.#index,
1873
+ end: this.#index
1736
1874
  });
1737
1875
  return;
1738
1876
  }
@@ -1747,12 +1885,14 @@ export class MdzLexer {
1747
1885
  this.#max_search_index = next_close;
1748
1886
  this.#tokenize_inline();
1749
1887
  }
1888
+ this.#inline_depth--;
1750
1889
 
1751
1890
  // No valid closer left — revert: drop the open token and all consumed
1752
1891
  // children tokens, emit `<` as text, and re-lex from the next char
1753
1892
  // (the `#revert_tokens_from_link_open` pattern; the old fallthrough
1754
1893
  // left a dangling `tag_open` whose parser fallback discarded content)
1755
1894
  this.#max_search_index = saved_max;
1895
+ this.#failed_tag.add(`${start}:${bound}`);
1756
1896
  this.#tokens.splice(open_token_index);
1757
1897
  this.#index = start + 1;
1758
1898
  this.#emit_text('<', start);
@@ -1780,16 +1920,33 @@ export class MdzLexer {
1780
1920
  let parts: Array<string> | null = null;
1781
1921
  let part_start = start;
1782
1922
 
1783
- while (this.#index < this.#text.length) {
1923
+ // in a table cell the trimmed cell end is a hard stop (no newline to
1924
+ // halt on mid-line) — hoisted to the loop bound
1925
+ const scan_end = this.#in_table_cell
1926
+ ? Math.min(this.#max_search_index, this.#text.length)
1927
+ : this.#text.length;
1928
+
1929
+ while (this.#index < scan_end) {
1784
1930
  const char_code = this.#text.charCodeAt(this.#index);
1931
+ // one table load classifies the char; zero (the common case — the
1932
+ // char can't end or interrupt the run) skips the dispatch below.
1933
+ // `TEXT_SCAN_CLASS` is shared with the streaming scanner
1934
+ // (`consume_text_run` in `mdz_stream_parser_text.ts`); the two dispatch
1935
+ // bodies intentionally differ around it — this one handles `\|` escapes
1936
+ // and list-run strip, that one URL speculation across chunk boundaries.
1937
+ const char_class = char_code < 128 ? TEXT_SCAN_CLASS[char_code]! : 0;
1938
+ if (char_class === 0) {
1939
+ this.#index++;
1940
+ continue;
1941
+ }
1942
+
1943
+ // Stop at special characters
1944
+ if ((char_class & TEXT_CLASS_STOP) !== 0) break;
1785
1945
 
1786
- // in a table cell: the trimmed cell end is a hard stop (no newline to
1787
- // halt on mid-line), and `\|` is a literal pipe (the only mdz escape,
1788
- // scoped here)
1789
- if (this.#in_table_cell) {
1790
- if (this.#index >= this.#max_search_index) break;
1946
+ if ((char_class & TEXT_CLASS_BACKSLASH) !== 0) {
1947
+ // `\|` is a literal pipe — the only mdz escape, scoped to table cells
1791
1948
  if (
1792
- char_code === BACKSLASH &&
1949
+ this.#in_table_cell &&
1793
1950
  this.#index + 1 < this.#max_search_index &&
1794
1951
  this.#text.charCodeAt(this.#index + 1) === PIPE
1795
1952
  ) {
@@ -1799,22 +1956,11 @@ export class MdzLexer {
1799
1956
  part_start = this.#index;
1800
1957
  continue;
1801
1958
  }
1959
+ this.#index++;
1960
+ continue;
1802
1961
  }
1803
1962
 
1804
- // Stop at special characters
1805
- if (
1806
- char_code === BACKTICK ||
1807
- char_code === ASTERISK ||
1808
- char_code === UNDERSCORE ||
1809
- char_code === TILDE ||
1810
- char_code === LEFT_BRACKET ||
1811
- char_code === RIGHT_BRACKET ||
1812
- char_code === LEFT_ANGLE
1813
- ) {
1814
- break;
1815
- }
1816
-
1817
- if (char_code === NEWLINE) {
1963
+ if ((char_class & TEXT_CLASS_NEWLINE) !== 0) {
1818
1964
  if (this.#list_run_strip) {
1819
1965
  if (this.#index >= this.#max_search_index) break; // the run's terminator
1820
1966
  // soft break: keep the newline, strip the next line's leading
@@ -1853,20 +1999,24 @@ export class MdzLexer {
1853
1999
  break;
1854
2000
  }
1855
2001
  }
2002
+ this.#index++; // single newline — ordinary text
2003
+ continue;
1856
2004
  }
1857
2005
 
1858
- // Check for URL or internal path mid-text (char code guard avoids startsWith on every char)
1859
- if (
1860
- ((char_code === 104 /* h */ || char_code === 72) /* H */ && this.#is_at_url()) ||
1861
- (char_code === SLASH && is_at_absolute_path(this.#text, this.#index)) ||
1862
- (char_code === PERIOD && is_at_relative_path(this.#text, this.#index))
1863
- ) {
2006
+ // URL or internal path mid-text (class guard avoids probing every char)
2007
+ if ((char_class & TEXT_CLASS_URL) !== 0) {
2008
+ if (this.#is_at_url()) break;
2009
+ } else if (char_code === SLASH) {
2010
+ if (is_at_absolute_path(this.#text, this.#index)) break;
2011
+ } else if (is_at_relative_path(this.#text, this.#index)) {
1864
2012
  break;
1865
2013
  }
1866
2014
 
1867
2015
  this.#index++;
1868
2016
  }
1869
2017
 
2018
+ if (mdz_debug_work.enabled) mdz_debug_work.total += this.#index - start;
2019
+
1870
2020
  // Ensure we always consume at least one character
1871
2021
  if (this.#index === start && this.#index < this.#text.length) {
1872
2022
  this.#index++;
@@ -1876,7 +2026,7 @@ export class MdzLexer {
1876
2026
  parts === null
1877
2027
  ? this.#text.slice(start, this.#index)
1878
2028
  : parts.join('') + this.#text.slice(part_start, this.#index);
1879
- this.#tokens.push({type: 'text', content, start, end: this.#index});
2029
+ this.#tokens.push({ type: 'text', content, start, end: this.#index });
1880
2030
  }
1881
2031
 
1882
2032
  // -- Auto-link tokenizers --
@@ -1905,7 +2055,7 @@ export class MdzLexer {
1905
2055
  reference,
1906
2056
  link_type: 'external',
1907
2057
  start,
1908
- end: this.#index,
2058
+ end: this.#index
1909
2059
  });
1910
2060
  }
1911
2061
 
@@ -1930,14 +2080,14 @@ export class MdzLexer {
1930
2080
  reference,
1931
2081
  link_type: 'internal',
1932
2082
  start,
1933
- end: this.#index,
2083
+ end: this.#index
1934
2084
  });
1935
2085
  }
1936
2086
 
1937
2087
  // -- Helper methods --
1938
2088
 
1939
2089
  #emit_text(content: string, start: number): void {
1940
- this.#tokens.push({type: 'text', content, start, end: start + content.length});
2090
+ this.#tokens.push({ type: 'text', content, start, end: start + content.length });
1941
2091
  }
1942
2092
 
1943
2093
  #has_paragraph_break_between(from: number, to: number): boolean {
@@ -1976,7 +2126,7 @@ export class MdzLexer {
1976
2126
  i = this.#text.indexOf('\n', j);
1977
2127
  }
1978
2128
  if (memo === null) {
1979
- this.#break_memo = {from, result};
2129
+ this.#break_memo = { from, result };
1980
2130
  } else {
1981
2131
  memo.from = from;
1982
2132
  memo.result = result;