@fuzdev/mdz 0.1.0 → 0.2.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 (60) hide show
  1. package/README.md +4 -0
  2. package/dist/MdzNodeView.svelte +30 -1
  3. package/dist/MdzNodeView.svelte.d.ts.map +1 -1
  4. package/dist/MdzStreamNodeView.svelte +31 -0
  5. package/dist/MdzStreamNodeView.svelte.d.ts.map +1 -1
  6. package/dist/context_helpers.d.ts +1 -1
  7. package/dist/mdz.d.ts +53 -2
  8. package/dist/mdz.d.ts.map +1 -1
  9. package/dist/mdz.js +29 -1
  10. package/dist/mdz_helpers.d.ts +35 -1
  11. package/dist/mdz_helpers.d.ts.map +1 -1
  12. package/dist/mdz_helpers.js +120 -0
  13. package/dist/mdz_lexer.d.ts +35 -1
  14. package/dist/mdz_lexer.d.ts.map +1 -1
  15. package/dist/mdz_lexer.js +211 -2
  16. package/dist/mdz_opcodes.d.ts +6 -1
  17. package/dist/mdz_opcodes.d.ts.map +1 -1
  18. package/dist/mdz_opcodes_to_nodes.d.ts.map +1 -1
  19. package/dist/mdz_opcodes_to_nodes.js +26 -0
  20. package/dist/mdz_stream_parser.d.ts.map +1 -1
  21. package/dist/mdz_stream_parser.js +20 -1
  22. package/dist/mdz_stream_parser_list.d.ts.map +1 -1
  23. package/dist/mdz_stream_parser_list.js +22 -1
  24. package/dist/mdz_stream_parser_state.d.ts +12 -0
  25. package/dist/mdz_stream_parser_state.d.ts.map +1 -1
  26. package/dist/mdz_stream_parser_state.js +1 -0
  27. package/dist/mdz_stream_parser_table.d.ts +72 -0
  28. package/dist/mdz_stream_parser_table.d.ts.map +1 -0
  29. package/dist/mdz_stream_parser_table.js +351 -0
  30. package/dist/mdz_stream_state.svelte.d.ts +5 -0
  31. package/dist/mdz_stream_state.svelte.d.ts.map +1 -1
  32. package/dist/mdz_stream_state.svelte.js +8 -0
  33. package/dist/mdz_to_svelte.d.ts.map +1 -1
  34. package/dist/mdz_to_svelte.js +37 -3
  35. package/dist/mdz_token_parser.d.ts +16 -0
  36. package/dist/mdz_token_parser.d.ts.map +1 -1
  37. package/dist/mdz_token_parser.js +101 -2
  38. package/dist/svelte_preprocess_mdz.js +2 -2
  39. package/package.json +8 -8
  40. package/src/lib/context_helpers.ts +1 -1
  41. package/src/lib/mdz.ts +70 -3
  42. package/src/lib/mdz_contexts.ts +1 -1
  43. package/src/lib/mdz_helpers.ts +125 -1
  44. package/src/lib/mdz_lexer.ts +262 -3
  45. package/src/lib/mdz_opcodes.ts +10 -1
  46. package/src/lib/mdz_opcodes_to_nodes.ts +37 -3
  47. package/src/lib/mdz_stream_parser.ts +26 -9
  48. package/src/lib/mdz_stream_parser_block.ts +2 -2
  49. package/src/lib/mdz_stream_parser_inline.ts +4 -4
  50. package/src/lib/mdz_stream_parser_link.ts +3 -3
  51. package/src/lib/mdz_stream_parser_list.ts +24 -2
  52. package/src/lib/mdz_stream_parser_state.ts +12 -2
  53. package/src/lib/mdz_stream_parser_table.ts +409 -0
  54. package/src/lib/mdz_stream_parser_text.ts +2 -2
  55. package/src/lib/mdz_stream_parser_url.ts +2 -2
  56. package/src/lib/mdz_stream_state.svelte.ts +8 -1
  57. package/src/lib/mdz_to_svelte.ts +39 -5
  58. package/src/lib/mdz_token_parser.ts +122 -5
  59. package/src/lib/svelte_preprocess_mdz.ts +4 -4
  60. package/src/lib/tsdoc_mdz.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/mdz",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "a strict markdown dialect built for streaming, Svelte authoring, docs websites, and untrusted content",
5
5
  "tagline": "markdown for fuz",
6
6
  "glyph": "📝",
@@ -57,17 +57,17 @@
57
57
  },
58
58
  "devDependencies": {
59
59
  "@fuzdev/blake3_wasm": "^0.1.1",
60
- "@fuzdev/fuz_code": "^0.46.0",
61
- "@fuzdev/fuz_css": "^0.62.0",
62
- "@fuzdev/fuz_ui": "^0.204.0",
60
+ "@fuzdev/fuz_code": "^0.46.1",
61
+ "@fuzdev/fuz_css": "^0.63.2",
62
+ "@fuzdev/fuz_ui": "^0.205.1",
63
63
  "@fuzdev/fuz_util": "^0.65.1",
64
- "@fuzdev/gro": "^0.204.0",
64
+ "@fuzdev/gro": "^0.205.1",
65
65
  "@ryanatkn/eslint-config": "^0.12.1",
66
66
  "@sveltejs/acorn-typescript": "^1.0.10",
67
67
  "@sveltejs/adapter-static": "^3.0.10",
68
- "@sveltejs/kit": "^2.65.0",
68
+ "@sveltejs/kit": "2.64.0",
69
69
  "@sveltejs/package": "^2.5.7",
70
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
70
+ "@sveltejs/vite-plugin-svelte": "^7.1.2",
71
71
  "@types/estree": "^1.0.8",
72
72
  "@types/node": "^24.12.4",
73
73
  "@webref/css": "^8.2.0",
@@ -83,7 +83,7 @@
83
83
  "tslib": "^2.8.1",
84
84
  "typescript": "^5.9.3",
85
85
  "typescript-eslint": "^8.48.1",
86
- "vite": "^7.3.1",
86
+ "vite": "^8.0.16",
87
87
  "vitest": "^4.0.15"
88
88
  },
89
89
  "prettier": {
@@ -14,7 +14,7 @@ import {getContext, setContext} from 'svelte';
14
14
  * If a `fallback` is provided, the `value` argument to `set` is optional
15
15
  * and `get_maybe` is omitted from the type.
16
16
  *
17
- * Vendored from `@fuzdev/fuz_ui/context_helpers.js` — fuz_ui sits above mdz
17
+ * Vendored from `@fuzdev/fuz_ui/context_helpers.ts` — fuz_ui sits above mdz
18
18
  * in the dependency graph, so mdz keeps its own copy for `mdz_contexts.ts`.
19
19
  * Consumers should import fuz_ui's.
20
20
  *
package/src/lib/mdz.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * - inline code in backticks (creates `Code` nodes; auto-linking to identifiers/modules
20
20
  * is handled by the rendering layer via `MdzNodeView.svelte`)
21
21
  * - paragraph breaks (blank lines)
22
- * - block elements: headings, horizontal rules, lists, blockquotes, code blocks
22
+ * - block elements: headings, horizontal rules, lists, blockquotes, code blocks, tables
23
23
  * - HTML elements and Svelte components (opt-in via context)
24
24
  *
25
25
  * Whitespace: the parser preserves whitespace in text node content (single
@@ -50,8 +50,8 @@
50
50
  * @module
51
51
  */
52
52
 
53
- import {MdzLexer} from './mdz_lexer.js';
54
- import {MdzTokenParser} from './mdz_token_parser.js';
53
+ import {MdzLexer} from './mdz_lexer.ts';
54
+ import {MdzTokenParser} from './mdz_token_parser.ts';
55
55
 
56
56
  // TODO design incremental parsing or some system that preserves Svelte components across re-renders when possible
57
57
 
@@ -64,6 +64,37 @@ import {MdzTokenParser} from './mdz_token_parser.js';
64
64
  export const mdz_parse = (text: string): Array<MdzNode> =>
65
65
  new MdzTokenParser(new MdzLexer(text).tokenize()).parse();
66
66
 
67
+ /**
68
+ * Parses table cells' inline content for the streaming parser, sharing one
69
+ * `MdzLexer` + `MdzTokenParser` across a row's cells rather than allocating a
70
+ * fresh pair (and search-memo `Map`) per cell. Bind to the buffer holding the
71
+ * row, then call `parse` once per cell `[cell_start, cell_end)` span; each
72
+ * call yields inline `MdzNode`s honoring code-span protection and the `\|`
73
+ * escape, matching the sync reference exactly — it *is* the sync lexer/parser,
74
+ * reset per cell, mirroring how the sync lexer already reuses one instance
75
+ * across a table's cells.
76
+ *
77
+ * The sync pipeline never needs this seam: its `MdzLexer` tokenizes a table's
78
+ * cells in place. Only the streaming path, which parses each fully-buffered
79
+ * cell out of band, would otherwise allocate per cell.
80
+ *
81
+ * @nodocs
82
+ */
83
+ export class MdzTableCellParser {
84
+ #lexer: MdzLexer;
85
+ #parser: MdzTokenParser = new MdzTokenParser([]);
86
+
87
+ /** @param text - the buffer the cell spans index into (stable across a row). */
88
+ constructor(text: string) {
89
+ this.#lexer = new MdzLexer(text);
90
+ }
91
+
92
+ parse(cell_start: number, cell_end: number): Array<MdzNode> {
93
+ this.#parser.reset(this.#lexer.lex_table_cell(cell_start, cell_end));
94
+ return this.#parser.parse_inline_run();
95
+ }
96
+ }
97
+
67
98
  export type MdzNode =
68
99
  | MdzNodeText
69
100
  | MdzNodeCode
@@ -78,6 +109,9 @@ export type MdzNode =
78
109
  | MdzNodeList
79
110
  | MdzNodeListItem
80
111
  | MdzNodeBlockquote
112
+ | MdzNodeTable
113
+ | MdzNodeTableRow
114
+ | MdzNodeTableCell
81
115
  | MdzNodeElement
82
116
  | MdzNodeComponent;
83
117
 
@@ -175,6 +209,39 @@ export interface MdzNodeBlockquote extends MdzNodeBase {
175
209
  children: Array<MdzNode>;
176
210
  }
177
211
 
212
+ /** Per-column text alignment from the delimiter row's colons; `null` is the default (no alignment). */
213
+ export type MdzTableAlign = 'left' | 'center' | 'right' | null;
214
+
215
+ export interface MdzNodeTable extends MdzNodeBase {
216
+ type: 'Table';
217
+ /**
218
+ * Per-column alignment, length equal to the delimiter row's column count.
219
+ * Rendered as `text-align` on each cell; tooling can read it to re-theme
220
+ * (e.g. a CSP-strict consumer that avoids inline styles).
221
+ */
222
+ align: Array<MdzTableAlign>;
223
+ /** The header row first, then any body rows. */
224
+ children: Array<MdzNodeTableRow>;
225
+ }
226
+
227
+ export interface MdzNodeTableRow extends MdzNodeBase {
228
+ type: 'TableRow';
229
+ /** `true` for the header row (renders `thead`/`th`); `false` for body rows (`tbody`/`td`). */
230
+ header: boolean;
231
+ /**
232
+ * The row's authored cells, preserved as written. The renderer normalizes to
233
+ * the column count: short rows are padded with empty cells, cells past the
234
+ * column count are ignored (their content survives here for tooling).
235
+ */
236
+ children: Array<MdzNodeTableCell>;
237
+ }
238
+
239
+ export interface MdzNodeTableCell extends MdzNodeBase {
240
+ type: 'TableCell';
241
+ /** Inline content only (text, bold, italic, strikethrough, code, links). */
242
+ children: Array<MdzNode>;
243
+ }
244
+
178
245
  export interface MdzNodeElement extends MdzNodeBase {
179
246
  type: 'Element';
180
247
  /** HTML element name (e.g. `div`, `span`, `aside`). */
@@ -1,6 +1,6 @@
1
1
  import type {Component} from 'svelte';
2
2
 
3
- import {create_context} from './context_helpers.js';
3
+ import {create_context} from './context_helpers.ts';
4
4
 
5
5
  /**
6
6
  * Component registry for custom Svelte components that can be used in mdz content.
@@ -7,7 +7,7 @@
7
7
  * @module
8
8
  */
9
9
 
10
- import type {MdzNode, MdzNodeText, MdzNodeComponent, MdzNodeElement} from './mdz.js';
10
+ import type {MdzNode, MdzNodeText, MdzNodeComponent, MdzNodeElement, MdzTableAlign} from './mdz.ts';
11
11
 
12
12
  // Character codes for performance
13
13
  /** @nodocs */
@@ -47,6 +47,10 @@ export const RIGHT_PAREN = 41; // )
47
47
  /** @nodocs */
48
48
  export const COLON = 58; // :
49
49
  /** @nodocs */
50
+ export const PIPE = 124; // |
51
+ /** @nodocs */
52
+ export const BACKSLASH = 92; // \
53
+ /** @nodocs */
50
54
  export const PERIOD = 46; // .
51
55
  /** @nodocs */
52
56
  export const COMMA = 44; // ,
@@ -287,6 +291,126 @@ export const mdz_remap_segments = (
287
291
  return seg.source_start + (p - seg.inner_start);
288
292
  };
289
293
 
294
+ // -- Table helpers --
295
+
296
+ /** Trim line-whitespace from both ends of a `[start, end)` span. */
297
+ const trim_table_cell = (
298
+ text: string,
299
+ start: number,
300
+ end: number,
301
+ ): {start: number; end: number} => {
302
+ let s = start;
303
+ let e = end;
304
+ while (s < e && is_line_whitespace(text.charCodeAt(s))) s++;
305
+ while (e > s && is_line_whitespace(text.charCodeAt(e - 1))) e--;
306
+ return {start: s, end: e};
307
+ };
308
+
309
+ /**
310
+ * Split a table row line `[line_start, line_end)` into its cell-content spans.
311
+ *
312
+ * A valid row starts with `|` and ends — after optional trailing whitespace —
313
+ * with `|`, yielding at least one cell. Returns each cell's trimmed
314
+ * `[start, end)` source span, or `null` when the line is not a valid pipe row
315
+ * (no leading `|`, no trailing `|`, or zero cells).
316
+ *
317
+ * Pipe-finding honors two protections so the cell content can hold a literal
318
+ * `|`: an inline-code span (a `` ` `` to its matching `` ` `` on the same line —
319
+ * an unclosed `` ` `` is literal and protects nothing) shields the pipes inside
320
+ * it, and a `\|` escape is consumed as a literal pipe. The escape is
321
+ * table-cell-scoped — the caller's inline tokenizer performs the matching
322
+ * `\|` → `|` substitution; this scanner only declines to split there.
323
+ *
324
+ * @nodocs
325
+ */
326
+ export const mdz_split_table_row = (
327
+ text: string,
328
+ line_start: number,
329
+ line_end: number,
330
+ ): Array<{start: number; end: number}> | null => {
331
+ if (line_start >= line_end || text.charCodeAt(line_start) !== PIPE) return null;
332
+ const cells: Array<{start: number; end: number}> = [];
333
+ let i = line_start + 1; // past the leading pipe
334
+ let cell_start = i;
335
+ while (i < line_end) {
336
+ const char_code = text.charCodeAt(i);
337
+ if (char_code === BACKTICK) {
338
+ // inline-code span: skip to the matching backtick; unclosed → literal
339
+ let close = -1;
340
+ for (let k = i + 1; k < line_end; k++) {
341
+ if (text.charCodeAt(k) === BACKTICK) {
342
+ close = k;
343
+ break;
344
+ }
345
+ }
346
+ if (close === -1) {
347
+ i++;
348
+ } else {
349
+ i = close + 1;
350
+ }
351
+ continue;
352
+ }
353
+ if (char_code === BACKSLASH) {
354
+ // `\|` is a literal pipe, never a delimiter
355
+ i += i + 1 < line_end && text.charCodeAt(i + 1) === PIPE ? 2 : 1;
356
+ continue;
357
+ }
358
+ if (char_code === PIPE) {
359
+ cells.push(trim_table_cell(text, cell_start, i));
360
+ i++;
361
+ cell_start = i;
362
+ continue;
363
+ }
364
+ i++;
365
+ }
366
+ // the trailing pipe is required: only whitespace may follow the last one
367
+ for (let k = cell_start; k < line_end; k++) {
368
+ if (!is_line_whitespace(text.charCodeAt(k))) return null;
369
+ }
370
+ if (cells.length === 0) return null;
371
+ return cells;
372
+ };
373
+
374
+ /** Classify a trimmed delimiter cell `[start, end)`; `false` when it is not a valid delimiter cell. */
375
+ const table_delimiter_align = (text: string, start: number, end: number): MdzTableAlign | false => {
376
+ let i = start;
377
+ let j = end;
378
+ if (i >= j) return false;
379
+ const left = text.charCodeAt(i) === COLON;
380
+ if (left) i++;
381
+ const right = j > i && text.charCodeAt(j - 1) === COLON;
382
+ if (right) j--;
383
+ if (i >= j) return false; // needs at least one dash
384
+ for (let k = i; k < j; k++) {
385
+ if (text.charCodeAt(k) !== HYPHEN) return false;
386
+ }
387
+ return left && right ? 'center' : left ? 'left' : right ? 'right' : null;
388
+ };
389
+
390
+ /**
391
+ * Parse a table delimiter row (`| --- | :-: | ---: |`) into its per-column
392
+ * alignment, or `null` when the line is not a valid delimiter row. Each cell
393
+ * must be an optional leading `:`, one or more `-`, and an optional trailing
394
+ * `:`. Splits with `mdz_split_table_row` so the column count matches the header.
395
+ *
396
+ * @nodocs
397
+ */
398
+ export const mdz_parse_table_delimiter = (
399
+ text: string,
400
+ line_start: number,
401
+ line_end: number,
402
+ ): Array<MdzTableAlign> | null => {
403
+ const cells = mdz_split_table_row(text, line_start, line_end);
404
+ if (cells === null) return null;
405
+ const align: Array<MdzTableAlign> = [];
406
+ for (const cell of cells) {
407
+ const a = table_delimiter_align(text, cell.start, cell.end);
408
+ if (a === false) return null;
409
+ align.push(a);
410
+ }
411
+ return align;
412
+ };
413
+
290
414
  /**
291
415
  * Lowercase an ASCII letter code; passes other chars through.
292
416
  * Mirrors the `[A-Z] → [a-z]` shift without touching non-ASCII.
@@ -47,7 +47,13 @@ import {
47
47
  mdz_blockquote_line_has_content,
48
48
  mdz_blockquote_strip_width,
49
49
  mdz_remap_segments,
50
- } from './mdz_helpers.js';
50
+ mdz_split_table_row,
51
+ mdz_parse_table_delimiter,
52
+ PIPE,
53
+ BACKSLASH,
54
+ } from './mdz_helpers.ts';
55
+
56
+ import type {MdzTableAlign} from './mdz.ts';
51
57
 
52
58
  //
53
59
  // Token types
@@ -84,7 +90,13 @@ export type MdzToken =
84
90
  | MdzTokenListItemOpen
85
91
  | MdzTokenListItemClose
86
92
  | MdzTokenBlockquoteOpen
87
- | MdzTokenBlockquoteClose;
93
+ | MdzTokenBlockquoteClose
94
+ | MdzTokenTableOpen
95
+ | MdzTokenTableClose
96
+ | MdzTokenTableRowOpen
97
+ | MdzTokenTableRowClose
98
+ | MdzTokenTableCellOpen
99
+ | MdzTokenTableCellClose;
88
100
 
89
101
  export interface MdzTokenText extends MdzTokenBase {
90
102
  type: 'text';
@@ -207,6 +219,34 @@ export interface MdzTokenBlockquoteClose extends MdzTokenBase {
207
219
  type: 'blockquote_close';
208
220
  }
209
221
 
222
+ export interface MdzTokenTableOpen extends MdzTokenBase {
223
+ type: 'table_open';
224
+ /** Per-column alignment from the delimiter row; length is the column count. */
225
+ align: Array<MdzTableAlign>;
226
+ }
227
+
228
+ export interface MdzTokenTableClose extends MdzTokenBase {
229
+ type: 'table_close';
230
+ }
231
+
232
+ export interface MdzTokenTableRowOpen extends MdzTokenBase {
233
+ type: 'table_row_open';
234
+ /** `true` for the header row, `false` for body rows. */
235
+ header: boolean;
236
+ }
237
+
238
+ export interface MdzTokenTableRowClose extends MdzTokenBase {
239
+ type: 'table_row_close';
240
+ }
241
+
242
+ export interface MdzTokenTableCellOpen extends MdzTokenBase {
243
+ type: 'table_cell_open';
244
+ }
245
+
246
+ export interface MdzTokenTableCellClose extends MdzTokenBase {
247
+ type: 'table_cell_close';
248
+ }
249
+
210
250
  /**
211
251
  * A matched list marker: `- ` (unordered) or `N. ` (ordered, 1-9 digits).
212
252
  * `empty` markers have nothing but whitespace after the marker — valid only
@@ -258,6 +298,12 @@ export class MdzLexer {
258
298
  * scanner pre-validated every line in the run as continuation).
259
299
  */
260
300
  #list_run_strip: boolean = false;
301
+ /**
302
+ * Whether `#tokenize_text` is inside a table cell: `#max_search_index` is a
303
+ * hard stop (the cell's trimmed end, mid-line with no newline to halt on)
304
+ * and `\|` unescapes to a literal pipe.
305
+ */
306
+ #in_table_cell: boolean = false;
261
307
 
262
308
  constructor(text: string) {
263
309
  this.#text = text;
@@ -300,6 +346,7 @@ export class MdzLexer {
300
346
  if (this.#tokenize_codeblock()) continue;
301
347
  if (this.#tokenize_list()) continue;
302
348
  if (this.#tokenize_blockquote()) continue;
349
+ if (this.#tokenize_table()) continue;
303
350
  }
304
351
 
305
352
  // Check for paragraph break
@@ -317,6 +364,22 @@ export class MdzLexer {
317
364
  return this.#tokens;
318
365
  }
319
366
 
367
+ /**
368
+ * Tokenize a table cell's inline content `[cell_start, cell_end)` as a
369
+ * bounded inline run, with no wrapping cell tokens (the bare form of
370
+ * `#emit_table_cell`, sharing `#tokenize_cell_inline`). The streaming parser
371
+ * reuses it across a row's cells via `MdzTableCellParser`: the token buffer
372
+ * resets per call while the search memo — valid over the immutable bound
373
+ * text — carries across cells (the same reuse the sync lexer does in place).
374
+ *
375
+ * @nodocs
376
+ */
377
+ lex_table_cell(cell_start: number, cell_end: number): Array<MdzToken> {
378
+ this.#tokens = [];
379
+ this.#tokenize_cell_inline(cell_start, cell_end);
380
+ return this.#tokens;
381
+ }
382
+
320
383
  #is_at_column_0(): boolean {
321
384
  return this.#index === 0 || this.#text.charCodeAt(this.#index - 1) === NEWLINE;
322
385
  }
@@ -604,6 +667,163 @@ export class MdzLexer {
604
667
  this.#index = last_line_end;
605
668
  }
606
669
 
670
+ // -- Table tokenizers --
671
+
672
+ /**
673
+ * Tokenize a table starting at a column-0 pipe row, emitting
674
+ * `table_open`/`table_row_open`/`table_cell_open` … structure tokens (the
675
+ * header row's cells first, then each body row's). Returns false with no
676
+ * side effects when the current line isn't a table: not a `| … |` row, or
677
+ * not followed by a delimiter row whose column count matches the header. The
678
+ * delimiter row's colons set `align`; body rows continue until a blank line,
679
+ * a non-pipe-row line, or EOF.
680
+ */
681
+ #tokenize_table(): boolean {
682
+ const table_start = this.#index;
683
+ let header_end = this.#index_of('\n', this.#index);
684
+ if (header_end === -1) header_end = this.#text.length;
685
+ const header_cells = mdz_split_table_row(this.#text, this.#index, header_end);
686
+ if (header_cells === null) return false;
687
+ if (header_end >= this.#text.length) return false; // a header needs a delimiter row beneath it
688
+ const delim_start = header_end + 1;
689
+ let delim_end = this.#index_of('\n', delim_start);
690
+ if (delim_end === -1) delim_end = this.#text.length;
691
+ const align = mdz_parse_table_delimiter(this.#text, delim_start, delim_end);
692
+ if (align === null || align.length !== header_cells.length) return false;
693
+
694
+ // 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});
696
+ this.#emit_table_row(header_cells, true, table_start, header_end);
697
+
698
+ this.#index = delim_end;
699
+ let table_end = delim_end;
700
+
701
+ // body rows: consecutive valid pipe rows, until a blank/non-row line or EOF
702
+ while (this.#index < this.#text.length) {
703
+ const row_start = this.#index + 1; // past the newline at #index
704
+ let row_end = this.#index_of('\n', row_start);
705
+ if (row_end === -1) row_end = this.#text.length;
706
+ const cells = mdz_split_table_row(this.#text, row_start, row_end);
707
+ if (cells === null) break;
708
+ this.#emit_table_row(cells, false, row_start, row_end);
709
+ this.#index = row_end;
710
+ table_end = row_end;
711
+ }
712
+
713
+ this.#tokens.push({type: 'table_close', start: table_end, end: table_end});
714
+ this.#skip_blank_lines();
715
+ return true;
716
+ }
717
+
718
+ /** Emit one table row's open / cell / close tokens. */
719
+ #emit_table_row(
720
+ cells: Array<{start: number; end: number}>,
721
+ header: boolean,
722
+ row_start: number,
723
+ row_end: number,
724
+ ): void {
725
+ this.#tokens.push({type: 'table_row_open', header, start: row_start, end: row_start});
726
+ for (const cell of cells) {
727
+ this.#emit_table_cell(cell.start, cell.end);
728
+ }
729
+ this.#tokens.push({type: 'table_row_close', start: row_end, end: row_end});
730
+ }
731
+
732
+ /**
733
+ * Emit one cell's `table_cell_open`, inline-content tokens, and
734
+ * `table_cell_close`. Inline tokenization is bounded to the cell's trimmed
735
+ * span via `#max_search_index`, with `#in_table_cell` set so `#tokenize_text`
736
+ * stops at that bound and unescapes `\|`.
737
+ */
738
+ #emit_table_cell(cell_start: number, cell_end: number): void {
739
+ this.#tokens.push({type: 'table_cell_open', start: cell_start, end: cell_start});
740
+ this.#tokenize_cell_inline(cell_start, cell_end);
741
+ this.#tokens.push({type: 'table_cell_close', start: cell_end, end: cell_end});
742
+ }
743
+
744
+ /**
745
+ * Tokenize a cell's trimmed inline span `[cell_start, cell_end)` into the
746
+ * current token buffer, bounded by `#max_search_index` with `#in_table_cell`
747
+ * set so `#tokenize_text` stops at the bound and unescapes `\|`. Shared by
748
+ * `#emit_table_cell` (wrapped in cell tokens) and `lex_table_cell` (bare).
749
+ */
750
+ #tokenize_cell_inline(cell_start: number, cell_end: number): void {
751
+ const saved_max = this.#max_search_index;
752
+ this.#max_search_index = cell_end;
753
+ this.#in_table_cell = true;
754
+ this.#index = cell_start;
755
+ while (this.#index < cell_end) {
756
+ this.#tokenize_inline();
757
+ }
758
+ this.#in_table_cell = false;
759
+ this.#max_search_index = saved_max;
760
+ }
761
+
762
+ /**
763
+ * Recognize an in-item table whose header row's leading `|` is at `first`
764
+ * (the line's indent already skipped): a pipe row whose next line is a
765
+ * delimiter row, both indented past `marker_indent` (so both stay inside the
766
+ * item). Returns the parsed header/delimiter, or null. Pure — no emission.
767
+ */
768
+ #match_item_table_head(
769
+ first: number,
770
+ marker_indent: number,
771
+ ): {
772
+ header_cells: Array<{start: number; end: number}>;
773
+ header_end: number;
774
+ align: Array<MdzTableAlign>;
775
+ delim_end: number;
776
+ } | null {
777
+ const header_end = this.#index_of('\n', first);
778
+ if (header_end === -1) return null; // a header needs a delimiter line beneath it
779
+ const header_cells = mdz_split_table_row(this.#text, first, header_end);
780
+ if (header_cells === null) return null;
781
+ const delim_line_start = header_end + 1;
782
+ let dj = delim_line_start;
783
+ while (dj < this.#text.length && is_line_whitespace(this.#text.charCodeAt(dj))) dj++;
784
+ if (dj - delim_line_start <= marker_indent) return null; // delimiter dedented out of the item
785
+ let delim_end = this.#index_of('\n', dj);
786
+ if (delim_end === -1) delim_end = this.#text.length;
787
+ const align = mdz_parse_table_delimiter(this.#text, dj, delim_end);
788
+ if (align === null || align.length !== header_cells.length) return null;
789
+ return {header_cells, header_end, align, delim_end};
790
+ }
791
+
792
+ /** Whether a table opens at `first` as a block child of an item at `marker_indent`. */
793
+ #is_item_table_start(first: number, marker_indent: number): boolean {
794
+ return this.#match_item_table_head(first, marker_indent) !== null;
795
+ }
796
+
797
+ /**
798
+ * Tokenize an in-item table at `first` (a recognized table start). Mirrors
799
+ * `#tokenize_table` but per row skips the line's indent to the `|` and ends
800
+ * when a row dedents to `marker_indent` or shallower, leaving `#index` at the
801
+ * table's terminating newline so the list loop classifies what follows (no
802
+ * `#skip_blank_lines` — the list owns blank-line containment).
803
+ */
804
+ #tokenize_item_table(first: number, marker_indent: number): void {
805
+ 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});
807
+ this.#emit_table_row(head.header_cells, true, first, head.header_end);
808
+ this.#index = head.delim_end;
809
+ let table_end = head.delim_end;
810
+ while (this.#index < this.#text.length) {
811
+ const row_line_start = this.#index + 1; // past the newline at #index
812
+ let rj = row_line_start;
813
+ while (rj < this.#text.length && is_line_whitespace(this.#text.charCodeAt(rj))) rj++;
814
+ if (rj - row_line_start <= marker_indent) break; // dedent ends the table
815
+ if (this.#text.charCodeAt(rj) !== PIPE) break;
816
+ let row_end = this.#index_of('\n', rj);
817
+ if (row_end === -1) row_end = this.#text.length;
818
+ const cells = mdz_split_table_row(this.#text, rj, row_end);
819
+ if (cells === null) break;
820
+ this.#emit_table_row(cells, false, rj, row_end);
821
+ this.#index = row_end;
822
+ table_end = row_end;
823
+ }
824
+ this.#tokens.push({type: 'table_close', start: table_end, end: table_end});
825
+ }
826
+
607
827
  // -- List tokenizers --
608
828
 
609
829
  /**
@@ -739,6 +959,23 @@ export class MdzLexer {
739
959
  }
740
960
  }
741
961
 
962
+ // a deeper pipe-row line whose next line is a delimiter becomes a
963
+ // table child of the item it would otherwise continue (the fence/quote
964
+ // precedent, but a two-line lookahead)
965
+ if (indent > 0 && this.#text.charCodeAt(first) === PIPE) {
966
+ const target_idx = this.#list_continuation_target(levels, indent);
967
+ if (target_idx !== -1) {
968
+ const marker_indent = levels[target_idx]!.indent;
969
+ if (this.#is_item_table_start(first, marker_indent)) {
970
+ while (levels.length - 1 > target_idx) {
971
+ this.#list_close(levels);
972
+ }
973
+ this.#tokenize_item_table(first, marker_indent);
974
+ continue;
975
+ }
976
+ }
977
+ }
978
+
742
979
  // any other line deeper than an open item's marker opens a new
743
980
  // `Paragraph` block child of that item (the item's inline run is never
744
981
  // current here — a blank, block child, or dedent intervened)
@@ -947,6 +1184,9 @@ export class MdzLexer {
947
1184
  if (this.#text.charCodeAt(j) === RIGHT_ANGLE && this.#is_blockquote_opener(j)) {
948
1185
  return nl; // a quote opener line interrupts the run
949
1186
  }
1187
+ if (this.#text.charCodeAt(j) === PIPE && this.#is_item_table_start(j, deepest_indent)) {
1188
+ return nl; // a table opener (pipe row + delimiter) interrupts the run
1189
+ }
950
1190
  i = line_start; // continuation — keep scanning
951
1191
  }
952
1192
  }
@@ -1543,6 +1783,24 @@ export class MdzLexer {
1543
1783
  while (this.#index < this.#text.length) {
1544
1784
  const char_code = this.#text.charCodeAt(this.#index);
1545
1785
 
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;
1791
+ if (
1792
+ char_code === BACKSLASH &&
1793
+ this.#index + 1 < this.#max_search_index &&
1794
+ this.#text.charCodeAt(this.#index + 1) === PIPE
1795
+ ) {
1796
+ (parts ??= []).push(this.#text.slice(part_start, this.#index));
1797
+ parts.push('|');
1798
+ this.#index += 2;
1799
+ part_start = this.#index;
1800
+ continue;
1801
+ }
1802
+ }
1803
+
1546
1804
  // Stop at special characters
1547
1805
  if (
1548
1806
  char_code === BACKTICK ||
@@ -1588,7 +1846,8 @@ export class MdzLexer {
1588
1846
  next_char === HYPHEN ||
1589
1847
  next_char === BACKTICK ||
1590
1848
  next_char === ONE ||
1591
- next_char === RIGHT_ANGLE
1849
+ next_char === RIGHT_ANGLE ||
1850
+ next_char === PIPE
1592
1851
  ) {
1593
1852
  this.#index++; // consume the newline
1594
1853
  break;
@@ -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
  /**