@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
@@ -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,
@@ -47,7 +48,18 @@ import {
47
48
  mdz_blockquote_line_has_content,
48
49
  mdz_blockquote_strip_width,
49
50
  mdz_remap_segments,
50
- } from './mdz_helpers.js';
51
+ mdz_split_table_row,
52
+ mdz_parse_table_delimiter,
53
+ PIPE,
54
+ TEXT_CLASS_BACKSLASH,
55
+ TEXT_CLASS_NEWLINE,
56
+ TEXT_CLASS_STOP,
57
+ TEXT_CLASS_URL,
58
+ TEXT_SCAN_CLASS,
59
+ } from './mdz_helpers.ts';
60
+
61
+ import type {MdzTableAlign} from './mdz.ts';
62
+ import {mdz_debug_work} from './mdz_debug_work.ts';
51
63
 
52
64
  //
53
65
  // Token types
@@ -84,7 +96,13 @@ export type MdzToken =
84
96
  | MdzTokenListItemOpen
85
97
  | MdzTokenListItemClose
86
98
  | MdzTokenBlockquoteOpen
87
- | MdzTokenBlockquoteClose;
99
+ | MdzTokenBlockquoteClose
100
+ | MdzTokenTableOpen
101
+ | MdzTokenTableClose
102
+ | MdzTokenTableRowOpen
103
+ | MdzTokenTableRowClose
104
+ | MdzTokenTableCellOpen
105
+ | MdzTokenTableCellClose;
88
106
 
89
107
  export interface MdzTokenText extends MdzTokenBase {
90
108
  type: 'text';
@@ -207,6 +225,34 @@ export interface MdzTokenBlockquoteClose extends MdzTokenBase {
207
225
  type: 'blockquote_close';
208
226
  }
209
227
 
228
+ export interface MdzTokenTableOpen extends MdzTokenBase {
229
+ type: 'table_open';
230
+ /** Per-column alignment from the delimiter row; length is the column count. */
231
+ align: Array<MdzTableAlign>;
232
+ }
233
+
234
+ export interface MdzTokenTableClose extends MdzTokenBase {
235
+ type: 'table_close';
236
+ }
237
+
238
+ export interface MdzTokenTableRowOpen extends MdzTokenBase {
239
+ type: 'table_row_open';
240
+ /** `true` for the header row, `false` for body rows. */
241
+ header: boolean;
242
+ }
243
+
244
+ export interface MdzTokenTableRowClose extends MdzTokenBase {
245
+ type: 'table_row_close';
246
+ }
247
+
248
+ export interface MdzTokenTableCellOpen extends MdzTokenBase {
249
+ type: 'table_cell_open';
250
+ }
251
+
252
+ export interface MdzTokenTableCellClose extends MdzTokenBase {
253
+ type: 'table_cell_close';
254
+ }
255
+
210
256
  /**
211
257
  * A matched list marker: `- ` (unordered) or `N. ` (ordered, 1-9 digits).
212
258
  * `empty` markers have nothing but whitespace after the marker — valid only
@@ -258,11 +304,75 @@ export class MdzLexer {
258
304
  * scanner pre-validated every line in the run as continuation).
259
305
  */
260
306
  #list_run_strip: boolean = false;
307
+ /**
308
+ * Whether `#tokenize_text` is inside a table cell: `#max_search_index` is a
309
+ * hard stop (the cell's trimmed end, mid-line with no newline to halt on)
310
+ * and `\|` unescapes to a literal pipe.
311
+ */
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();
261
350
 
262
351
  constructor(text: string) {
263
352
  this.#text = text;
264
353
  }
265
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
+
266
376
  /**
267
377
  * Memoized `String#indexOf` over the immutable text.
268
378
  *
@@ -279,6 +389,10 @@ export class MdzLexer {
279
389
  return memo.result;
280
390
  }
281
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
+ }
282
396
  if (memo === undefined) {
283
397
  this.#search_memo.set(needle, {from, result});
284
398
  } else {
@@ -300,6 +414,7 @@ export class MdzLexer {
300
414
  if (this.#tokenize_codeblock()) continue;
301
415
  if (this.#tokenize_list()) continue;
302
416
  if (this.#tokenize_blockquote()) continue;
417
+ if (this.#tokenize_table()) continue;
303
418
  }
304
419
 
305
420
  // Check for paragraph break
@@ -317,6 +432,25 @@ export class MdzLexer {
317
432
  return this.#tokens;
318
433
  }
319
434
 
435
+ /**
436
+ * Tokenize a table cell's inline content `[cell_start, cell_end)` as a
437
+ * bounded inline run, with no wrapping cell tokens (the bare form of
438
+ * `#emit_table_cell`, sharing `#tokenize_cell_inline`). The streaming parser
439
+ * reuses it across a row's cells via `MdzTableCellParser`: the token buffer
440
+ * resets per call while the search memo — valid over the immutable bound
441
+ * text — carries across cells (the same reuse the sync lexer does in place).
442
+ *
443
+ * @nodocs
444
+ */
445
+ lex_table_cell(cell_start: number, cell_end: number): Array<MdzToken> {
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;
450
+ this.#tokenize_cell_inline(cell_start, cell_end);
451
+ return this.#tokens;
452
+ }
453
+
320
454
  #is_at_column_0(): boolean {
321
455
  return this.#index === 0 || this.#text.charCodeAt(this.#index - 1) === NEWLINE;
322
456
  }
@@ -604,6 +738,163 @@ export class MdzLexer {
604
738
  this.#index = last_line_end;
605
739
  }
606
740
 
741
+ // -- Table tokenizers --
742
+
743
+ /**
744
+ * Tokenize a table starting at a column-0 pipe row, emitting
745
+ * `table_open`/`table_row_open`/`table_cell_open` … structure tokens (the
746
+ * header row's cells first, then each body row's). Returns false with no
747
+ * side effects when the current line isn't a table: not a `| … |` row, or
748
+ * not followed by a delimiter row whose column count matches the header. The
749
+ * delimiter row's colons set `align`; body rows continue until a blank line,
750
+ * a non-pipe-row line, or EOF.
751
+ */
752
+ #tokenize_table(): boolean {
753
+ const table_start = this.#index;
754
+ let header_end = this.#index_of('\n', this.#index);
755
+ if (header_end === -1) header_end = this.#text.length;
756
+ const header_cells = mdz_split_table_row(this.#text, this.#index, header_end);
757
+ if (header_cells === null) return false;
758
+ if (header_end >= this.#text.length) return false; // a header needs a delimiter row beneath it
759
+ const delim_start = header_end + 1;
760
+ let delim_end = this.#index_of('\n', delim_start);
761
+ if (delim_end === -1) delim_end = this.#text.length;
762
+ const align = mdz_parse_table_delimiter(this.#text, delim_start, delim_end);
763
+ if (align === null || align.length !== header_cells.length) return false;
764
+
765
+ // committed — emit the table; the delimiter row is structural (not emitted)
766
+ this.#tokens.push({type: 'table_open', align, start: table_start, end: header_end});
767
+ this.#emit_table_row(header_cells, true, table_start, header_end);
768
+
769
+ this.#index = delim_end;
770
+ let table_end = delim_end;
771
+
772
+ // body rows: consecutive valid pipe rows, until a blank/non-row line or EOF
773
+ while (this.#index < this.#text.length) {
774
+ const row_start = this.#index + 1; // past the newline at #index
775
+ let row_end = this.#index_of('\n', row_start);
776
+ if (row_end === -1) row_end = this.#text.length;
777
+ const cells = mdz_split_table_row(this.#text, row_start, row_end);
778
+ if (cells === null) break;
779
+ this.#emit_table_row(cells, false, row_start, row_end);
780
+ this.#index = row_end;
781
+ table_end = row_end;
782
+ }
783
+
784
+ this.#tokens.push({type: 'table_close', start: table_end, end: table_end});
785
+ this.#skip_blank_lines();
786
+ return true;
787
+ }
788
+
789
+ /** Emit one table row's open / cell / close tokens. */
790
+ #emit_table_row(
791
+ cells: Array<{start: number; end: number}>,
792
+ header: boolean,
793
+ row_start: number,
794
+ row_end: number,
795
+ ): void {
796
+ this.#tokens.push({type: 'table_row_open', header, start: row_start, end: row_start});
797
+ for (const cell of cells) {
798
+ this.#emit_table_cell(cell.start, cell.end);
799
+ }
800
+ this.#tokens.push({type: 'table_row_close', start: row_end, end: row_end});
801
+ }
802
+
803
+ /**
804
+ * Emit one cell's `table_cell_open`, inline-content tokens, and
805
+ * `table_cell_close`. Inline tokenization is bounded to the cell's trimmed
806
+ * span via `#max_search_index`, with `#in_table_cell` set so `#tokenize_text`
807
+ * stops at that bound and unescapes `\|`.
808
+ */
809
+ #emit_table_cell(cell_start: number, cell_end: number): void {
810
+ this.#tokens.push({type: 'table_cell_open', start: cell_start, end: cell_start});
811
+ this.#tokenize_cell_inline(cell_start, cell_end);
812
+ this.#tokens.push({type: 'table_cell_close', start: cell_end, end: cell_end});
813
+ }
814
+
815
+ /**
816
+ * Tokenize a cell's trimmed inline span `[cell_start, cell_end)` into the
817
+ * current token buffer, bounded by `#max_search_index` with `#in_table_cell`
818
+ * set so `#tokenize_text` stops at the bound and unescapes `\|`. Shared by
819
+ * `#emit_table_cell` (wrapped in cell tokens) and `lex_table_cell` (bare).
820
+ */
821
+ #tokenize_cell_inline(cell_start: number, cell_end: number): void {
822
+ const saved_max = this.#max_search_index;
823
+ this.#max_search_index = cell_end;
824
+ this.#in_table_cell = true;
825
+ this.#index = cell_start;
826
+ while (this.#index < cell_end) {
827
+ this.#tokenize_inline();
828
+ }
829
+ this.#in_table_cell = false;
830
+ this.#max_search_index = saved_max;
831
+ }
832
+
833
+ /**
834
+ * Recognize an in-item table whose header row's leading `|` is at `first`
835
+ * (the line's indent already skipped): a pipe row whose next line is a
836
+ * delimiter row, both indented past `marker_indent` (so both stay inside the
837
+ * item). Returns the parsed header/delimiter, or null. Pure — no emission.
838
+ */
839
+ #match_item_table_head(
840
+ first: number,
841
+ marker_indent: number,
842
+ ): {
843
+ header_cells: Array<{start: number; end: number}>;
844
+ header_end: number;
845
+ align: Array<MdzTableAlign>;
846
+ delim_end: number;
847
+ } | null {
848
+ const header_end = this.#index_of('\n', first);
849
+ if (header_end === -1) return null; // a header needs a delimiter line beneath it
850
+ const header_cells = mdz_split_table_row(this.#text, first, header_end);
851
+ if (header_cells === null) return null;
852
+ const delim_line_start = header_end + 1;
853
+ let dj = delim_line_start;
854
+ while (dj < this.#text.length && is_line_whitespace(this.#text.charCodeAt(dj))) dj++;
855
+ if (dj - delim_line_start <= marker_indent) return null; // delimiter dedented out of the item
856
+ let delim_end = this.#index_of('\n', dj);
857
+ if (delim_end === -1) delim_end = this.#text.length;
858
+ const align = mdz_parse_table_delimiter(this.#text, dj, delim_end);
859
+ if (align === null || align.length !== header_cells.length) return null;
860
+ return {header_cells, header_end, align, delim_end};
861
+ }
862
+
863
+ /** Whether a table opens at `first` as a block child of an item at `marker_indent`. */
864
+ #is_item_table_start(first: number, marker_indent: number): boolean {
865
+ return this.#match_item_table_head(first, marker_indent) !== null;
866
+ }
867
+
868
+ /**
869
+ * Tokenize an in-item table at `first` (a recognized table start). Mirrors
870
+ * `#tokenize_table` but per row skips the line's indent to the `|` and ends
871
+ * when a row dedents to `marker_indent` or shallower, leaving `#index` at the
872
+ * table's terminating newline so the list loop classifies what follows (no
873
+ * `#skip_blank_lines` — the list owns blank-line containment).
874
+ */
875
+ #tokenize_item_table(first: number, marker_indent: number): void {
876
+ const head = this.#match_item_table_head(first, marker_indent)!;
877
+ this.#tokens.push({type: 'table_open', align: head.align, start: first, end: head.header_end});
878
+ this.#emit_table_row(head.header_cells, true, first, head.header_end);
879
+ this.#index = head.delim_end;
880
+ let table_end = head.delim_end;
881
+ while (this.#index < this.#text.length) {
882
+ const row_line_start = this.#index + 1; // past the newline at #index
883
+ let rj = row_line_start;
884
+ while (rj < this.#text.length && is_line_whitespace(this.#text.charCodeAt(rj))) rj++;
885
+ if (rj - row_line_start <= marker_indent) break; // dedent ends the table
886
+ if (this.#text.charCodeAt(rj) !== PIPE) break;
887
+ let row_end = this.#index_of('\n', rj);
888
+ if (row_end === -1) row_end = this.#text.length;
889
+ const cells = mdz_split_table_row(this.#text, rj, row_end);
890
+ if (cells === null) break;
891
+ this.#emit_table_row(cells, false, rj, row_end);
892
+ this.#index = row_end;
893
+ table_end = row_end;
894
+ }
895
+ this.#tokens.push({type: 'table_close', start: table_end, end: table_end});
896
+ }
897
+
607
898
  // -- List tokenizers --
608
899
 
609
900
  /**
@@ -739,6 +1030,23 @@ export class MdzLexer {
739
1030
  }
740
1031
  }
741
1032
 
1033
+ // a deeper pipe-row line whose next line is a delimiter becomes a
1034
+ // table child of the item it would otherwise continue (the fence/quote
1035
+ // precedent, but a two-line lookahead)
1036
+ if (indent > 0 && this.#text.charCodeAt(first) === PIPE) {
1037
+ const target_idx = this.#list_continuation_target(levels, indent);
1038
+ if (target_idx !== -1) {
1039
+ const marker_indent = levels[target_idx]!.indent;
1040
+ if (this.#is_item_table_start(first, marker_indent)) {
1041
+ while (levels.length - 1 > target_idx) {
1042
+ this.#list_close(levels);
1043
+ }
1044
+ this.#tokenize_item_table(first, marker_indent);
1045
+ continue;
1046
+ }
1047
+ }
1048
+ }
1049
+
742
1050
  // any other line deeper than an open item's marker opens a new
743
1051
  // `Paragraph` block child of that item (the item's inline run is never
744
1052
  // current here — a blank, block child, or dedent intervened)
@@ -947,6 +1255,9 @@ export class MdzLexer {
947
1255
  if (this.#text.charCodeAt(j) === RIGHT_ANGLE && this.#is_blockquote_opener(j)) {
948
1256
  return nl; // a quote opener line interrupts the run
949
1257
  }
1258
+ if (this.#text.charCodeAt(j) === PIPE && this.#is_item_table_start(j, deepest_indent)) {
1259
+ return nl; // a table opener (pipe row + delimiter) interrupts the run
1260
+ }
950
1261
  i = line_start; // continuation — keep scanning
951
1262
  }
952
1263
  }
@@ -1301,6 +1612,23 @@ export class MdzLexer {
1301
1612
 
1302
1613
  #tokenize_markdown_link(): void {
1303
1614
  const start = this.#index;
1615
+ // a link open at this offset already failed, or was suppressed by the
1616
+ // nesting cap — emit `[` as text instead of re-descending into the same
1617
+ // failing children (exponential on nested `[`); the short-circuit
1618
+ // reproduces the revert's exact net effect (`[` text, cursor at start + 1)
1619
+ if (this.#failed_link_starts.has(start) || this.#depth_capped.has(start)) {
1620
+ this.#index = start + 1;
1621
+ this.#emit_text('[', start);
1622
+ return;
1623
+ }
1624
+ // nesting-depth cap: too deep to open — render literal and record the
1625
+ // suppression so a shallower re-lex short-circuits here too
1626
+ if (this.#inline_depth >= MAX_INLINE_NESTING_DEPTH) {
1627
+ this.#depth_capped.add(start);
1628
+ this.#index = start + 1;
1629
+ this.#emit_text('[', start);
1630
+ return;
1631
+ }
1304
1632
  this.#index++; // consume [ (dispatch guarantees it)
1305
1633
 
1306
1634
  // Emit link_text_open
@@ -1309,11 +1637,13 @@ export class MdzLexer {
1309
1637
  // Tokenize children until ] — only `]` delimits link text; a bare `)`
1310
1638
  // is ordinary content (matching the streaming parser, which has no `)`
1311
1639
  // dispatch case — the reference scan finds its own `)` after `](`)
1640
+ this.#inline_depth++;
1312
1641
  while (this.#index < this.#text.length) {
1313
1642
  if (this.#text.charCodeAt(this.#index) === RIGHT_BRACKET) break;
1314
1643
  if (this.#is_at_paragraph_break()) break;
1315
1644
  this.#tokenize_inline();
1316
1645
  }
1646
+ this.#inline_depth--;
1317
1647
 
1318
1648
  // Check for ]
1319
1649
  if (this.#index >= this.#text.length || this.#text.charCodeAt(this.#index) !== RIGHT_BRACKET) {
@@ -1382,6 +1712,13 @@ export class MdzLexer {
1382
1712
  }
1383
1713
 
1384
1714
  #revert_tokens_from_link_open(start: number): void {
1715
+ // record the failure so a re-lex from before this `[` short-circuits.
1716
+ // Keyed by `start` alone — unlike `#failed_tag`'s `start:bound` — because a
1717
+ // link's outcome is bound-independent: the `]`-terminator scan (the children
1718
+ // loop) runs to `text.length` and never consults `#max_search_index`, so a
1719
+ // wider-bound re-lex can't move it. The bound reaches nested tags inside the
1720
+ // link text, but re-attempting those wider never flips the link's net result.
1721
+ this.#failed_link_starts.add(start);
1385
1722
  // Find and remove link_text_open and all tokens after it
1386
1723
  let open_idx = -1;
1387
1724
  for (let i = this.#tokens.length - 1; i >= 0; i--) {
@@ -1399,6 +1736,32 @@ export class MdzLexer {
1399
1736
 
1400
1737
  #tokenize_tag(): void {
1401
1738
  const start = this.#index;
1739
+ // short-circuit a re-attempt of a tag open that already failed at this
1740
+ // offset+bound — re-descending into the same failing children is
1741
+ // exponential on nested same-name tags. Keyed by
1742
+ // `#max_search_index` (captured as `bound`) because the closer search is
1743
+ // bound-sensitive, so a wider-bound re-lex can legitimately succeed. The
1744
+ // key string is built only when the memo is non-empty (adversarial
1745
+ // input); prose never allocates it. The short-circuit reproduces a
1746
+ // revert's exact net effect (`<` text, cursor at start + 1).
1747
+ const bound = this.#max_search_index;
1748
+ if (
1749
+ this.#depth_capped.has(start) ||
1750
+ (this.#failed_tag.size !== 0 && this.#failed_tag.has(`${start}:${bound}`))
1751
+ ) {
1752
+ this.#index = start + 1;
1753
+ this.#emit_text('<', start);
1754
+ return;
1755
+ }
1756
+ // nesting-depth cap: too deep to open a tag — render literal and record
1757
+ // the suppression (position-keyed) so a shallower re-lex short-circuits
1758
+ // here too, keeping the revert re-scan linear
1759
+ if (this.#inline_depth >= MAX_INLINE_NESTING_DEPTH) {
1760
+ this.#depth_capped.add(start);
1761
+ this.#index = start + 1;
1762
+ this.#emit_text('<', start);
1763
+ return;
1764
+ }
1402
1765
  this.#index++; // consume <
1403
1766
 
1404
1767
  // Tag name must start with a letter
@@ -1445,6 +1808,7 @@ export class MdzLexer {
1445
1808
 
1446
1809
  // Check for >
1447
1810
  if (this.#index >= this.#text.length || this.#text.charCodeAt(this.#index) !== RIGHT_ANGLE) {
1811
+ this.#failed_tag.add(`${start}:${bound}`);
1448
1812
  this.#index = start + 1;
1449
1813
  this.#emit_text('<', start);
1450
1814
  return;
@@ -1463,11 +1827,13 @@ export class MdzLexer {
1463
1827
  const search_limit = Math.min(this.#max_search_index, this.#text.length);
1464
1828
  const closing_tag_pos = this.#index_of(closing_tag, this.#index);
1465
1829
  if (closing_tag_pos === -1 || closing_tag_pos > search_limit) {
1830
+ this.#failed_tag.add(`${start}:${bound}`);
1466
1831
  this.#index = start + 1;
1467
1832
  this.#emit_text('<', start);
1468
1833
  return;
1469
1834
  }
1470
1835
  if (this.#has_paragraph_break_between(this.#index, closing_tag_pos)) {
1836
+ this.#failed_tag.add(`${start}:${bound}`);
1471
1837
  this.#index = start + 1;
1472
1838
  this.#emit_text('<', start);
1473
1839
  return;
@@ -1483,8 +1849,10 @@ export class MdzLexer {
1483
1849
  // the code span). Recomputed per iteration because a nested same-name
1484
1850
  // tag legitimately consumes the nearest closer (`<b>x<b>y</b></b>`).
1485
1851
  const saved_max = this.#max_search_index;
1852
+ this.#inline_depth++;
1486
1853
  while (this.#index < this.#text.length) {
1487
1854
  if (this.#match(closing_tag)) {
1855
+ this.#inline_depth--;
1488
1856
  this.#max_search_index = saved_max;
1489
1857
  const close_start = this.#index;
1490
1858
  this.#index += closing_tag.length;
@@ -1507,12 +1875,14 @@ export class MdzLexer {
1507
1875
  this.#max_search_index = next_close;
1508
1876
  this.#tokenize_inline();
1509
1877
  }
1878
+ this.#inline_depth--;
1510
1879
 
1511
1880
  // No valid closer left — revert: drop the open token and all consumed
1512
1881
  // children tokens, emit `<` as text, and re-lex from the next char
1513
1882
  // (the `#revert_tokens_from_link_open` pattern; the old fallthrough
1514
1883
  // left a dangling `tag_open` whose parser fallback discarded content)
1515
1884
  this.#max_search_index = saved_max;
1885
+ this.#failed_tag.add(`${start}:${bound}`);
1516
1886
  this.#tokens.splice(open_token_index);
1517
1887
  this.#index = start + 1;
1518
1888
  this.#emit_text('<', start);
@@ -1540,23 +1910,47 @@ export class MdzLexer {
1540
1910
  let parts: Array<string> | null = null;
1541
1911
  let part_start = start;
1542
1912
 
1543
- while (this.#index < this.#text.length) {
1913
+ // in a table cell the trimmed cell end is a hard stop (no newline to
1914
+ // halt on mid-line) — hoisted to the loop bound
1915
+ const scan_end = this.#in_table_cell
1916
+ ? Math.min(this.#max_search_index, this.#text.length)
1917
+ : this.#text.length;
1918
+
1919
+ while (this.#index < scan_end) {
1544
1920
  const char_code = this.#text.charCodeAt(this.#index);
1921
+ // one table load classifies the char; zero (the common case — the
1922
+ // char can't end or interrupt the run) skips the dispatch below.
1923
+ // `TEXT_SCAN_CLASS` is shared with the streaming scanner
1924
+ // (`consume_text_run` in `mdz_stream_parser_text.ts`); the two dispatch
1925
+ // bodies intentionally differ around it — this one handles `\|` escapes
1926
+ // and list-run strip, that one URL speculation across chunk boundaries.
1927
+ const char_class = char_code < 128 ? TEXT_SCAN_CLASS[char_code]! : 0;
1928
+ if (char_class === 0) {
1929
+ this.#index++;
1930
+ continue;
1931
+ }
1545
1932
 
1546
1933
  // Stop at special characters
1547
- if (
1548
- char_code === BACKTICK ||
1549
- char_code === ASTERISK ||
1550
- char_code === UNDERSCORE ||
1551
- char_code === TILDE ||
1552
- char_code === LEFT_BRACKET ||
1553
- char_code === RIGHT_BRACKET ||
1554
- char_code === LEFT_ANGLE
1555
- ) {
1556
- break;
1934
+ if ((char_class & TEXT_CLASS_STOP) !== 0) break;
1935
+
1936
+ if ((char_class & TEXT_CLASS_BACKSLASH) !== 0) {
1937
+ // `\|` is a literal pipe — the only mdz escape, scoped to table cells
1938
+ if (
1939
+ this.#in_table_cell &&
1940
+ this.#index + 1 < this.#max_search_index &&
1941
+ this.#text.charCodeAt(this.#index + 1) === PIPE
1942
+ ) {
1943
+ (parts ??= []).push(this.#text.slice(part_start, this.#index));
1944
+ parts.push('|');
1945
+ this.#index += 2;
1946
+ part_start = this.#index;
1947
+ continue;
1948
+ }
1949
+ this.#index++;
1950
+ continue;
1557
1951
  }
1558
1952
 
1559
- if (char_code === NEWLINE) {
1953
+ if ((char_class & TEXT_CLASS_NEWLINE) !== 0) {
1560
1954
  if (this.#list_run_strip) {
1561
1955
  if (this.#index >= this.#max_search_index) break; // the run's terminator
1562
1956
  // soft break: keep the newline, strip the next line's leading
@@ -1588,26 +1982,31 @@ export class MdzLexer {
1588
1982
  next_char === HYPHEN ||
1589
1983
  next_char === BACKTICK ||
1590
1984
  next_char === ONE ||
1591
- next_char === RIGHT_ANGLE
1985
+ next_char === RIGHT_ANGLE ||
1986
+ next_char === PIPE
1592
1987
  ) {
1593
1988
  this.#index++; // consume the newline
1594
1989
  break;
1595
1990
  }
1596
1991
  }
1992
+ this.#index++; // single newline — ordinary text
1993
+ continue;
1597
1994
  }
1598
1995
 
1599
- // Check for URL or internal path mid-text (char code guard avoids startsWith on every char)
1600
- if (
1601
- ((char_code === 104 /* h */ || char_code === 72) /* H */ && this.#is_at_url()) ||
1602
- (char_code === SLASH && is_at_absolute_path(this.#text, this.#index)) ||
1603
- (char_code === PERIOD && is_at_relative_path(this.#text, this.#index))
1604
- ) {
1996
+ // URL or internal path mid-text (class guard avoids probing every char)
1997
+ if ((char_class & TEXT_CLASS_URL) !== 0) {
1998
+ if (this.#is_at_url()) break;
1999
+ } else if (char_code === SLASH) {
2000
+ if (is_at_absolute_path(this.#text, this.#index)) break;
2001
+ } else if (is_at_relative_path(this.#text, this.#index)) {
1605
2002
  break;
1606
2003
  }
1607
2004
 
1608
2005
  this.#index++;
1609
2006
  }
1610
2007
 
2008
+ if (mdz_debug_work.enabled) mdz_debug_work.total += this.#index - start;
2009
+
1611
2010
  // Ensure we always consume at least one character
1612
2011
  if (this.#index === start && this.#index < this.#text.length) {
1613
2012
  this.#index++;
@@ -12,6 +12,8 @@
12
12
  * @module
13
13
  */
14
14
 
15
+ import type {MdzTableAlign} from './mdz.ts';
16
+
15
17
  /**
16
18
  * Unique monotonic identifier for each node created by the parser.
17
19
  * IDs are never reused within a parser instance.
@@ -39,7 +41,10 @@ export type MdzNodeTypeContainer =
39
41
  | 'Element'
40
42
  | 'Component'
41
43
  | 'Codeblock'
42
- | 'Code';
44
+ | 'Code'
45
+ | 'Table'
46
+ | 'TableRow'
47
+ | 'TableCell';
43
48
 
44
49
  /** Node types for self-contained leaf elements. */
45
50
  export type MdzNodeTypeVoid = 'Hr';
@@ -69,6 +74,10 @@ export interface MdzOpcodeOpen {
69
74
  start_number?: number;
70
75
  /** Authored item number. Present when `node_type` is `'ListItem'` in an ordered list. */
71
76
  number?: number;
77
+ /** Per-column alignment from the delimiter row. Present when `node_type` is `'Table'`. */
78
+ align?: Array<MdzTableAlign>;
79
+ /** Whether this is the header row (`thead`/`th`). Present when `node_type` is `'TableRow'`. */
80
+ header?: boolean;
72
81
  }
73
82
 
74
83
  /**