@fuzdev/mdz 0.2.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 (56) hide show
  1. package/dist/Mdz.svelte +15 -7
  2. package/dist/Mdz.svelte.d.ts +9 -2
  3. package/dist/Mdz.svelte.d.ts.map +1 -1
  4. package/dist/MdzNodeView.svelte +118 -112
  5. package/dist/MdzNodeView.svelte.d.ts +6 -2
  6. package/dist/MdzNodeView.svelte.d.ts.map +1 -1
  7. package/dist/MdzStream.svelte +3 -3
  8. package/dist/MdzStream.svelte.d.ts.map +1 -1
  9. package/dist/MdzStreamNodeView.svelte +117 -114
  10. package/dist/MdzStreamNodeView.svelte.d.ts +6 -2
  11. package/dist/MdzStreamNodeView.svelte.d.ts.map +1 -1
  12. package/dist/mdz.d.ts +6 -0
  13. package/dist/mdz.d.ts.map +1 -1
  14. package/dist/mdz.js +8 -0
  15. package/dist/mdz_debug_work.d.ts +38 -0
  16. package/dist/mdz_debug_work.d.ts.map +1 -0
  17. package/dist/mdz_debug_work.js +37 -0
  18. package/dist/mdz_helpers.d.ts +61 -0
  19. package/dist/mdz_helpers.d.ts.map +1 -1
  20. package/dist/mdz_helpers.js +72 -0
  21. package/dist/mdz_lexer.d.ts +12 -0
  22. package/dist/mdz_lexer.d.ts.map +1 -1
  23. package/dist/mdz_lexer.js +164 -24
  24. package/dist/mdz_stream_parser_inline.d.ts.map +1 -1
  25. package/dist/mdz_stream_parser_inline.js +19 -17
  26. package/dist/mdz_stream_parser_link.d.ts.map +1 -1
  27. package/dist/mdz_stream_parser_link.js +88 -56
  28. package/dist/mdz_stream_parser_list.d.ts.map +1 -1
  29. package/dist/mdz_stream_parser_list.js +10 -2
  30. package/dist/mdz_stream_parser_state.d.ts +29 -0
  31. package/dist/mdz_stream_parser_state.d.ts.map +1 -1
  32. package/dist/mdz_stream_parser_state.js +52 -3
  33. package/dist/mdz_stream_parser_table.d.ts +3 -2
  34. package/dist/mdz_stream_parser_table.d.ts.map +1 -1
  35. package/dist/mdz_stream_parser_table.js +18 -7
  36. package/dist/mdz_stream_parser_text.d.ts.map +1 -1
  37. package/dist/mdz_stream_parser_text.js +35 -24
  38. package/dist/mdz_to_svelte.d.ts.map +1 -1
  39. package/dist/mdz_to_svelte.js +28 -31
  40. package/dist/mdz_token_parser.d.ts.map +1 -1
  41. package/dist/mdz_token_parser.js +15 -16
  42. package/dist/svelte_preprocess_mdz.js +31 -37
  43. package/package.json +7 -4
  44. package/src/lib/mdz.ts +9 -0
  45. package/src/lib/mdz_debug_work.ts +38 -0
  46. package/src/lib/mdz_helpers.ts +97 -0
  47. package/src/lib/mdz_lexer.ts +168 -28
  48. package/src/lib/mdz_stream_parser_inline.ts +21 -17
  49. package/src/lib/mdz_stream_parser_link.ts +89 -56
  50. package/src/lib/mdz_stream_parser_list.ts +10 -4
  51. package/src/lib/mdz_stream_parser_state.ts +76 -4
  52. package/src/lib/mdz_stream_parser_table.ts +18 -7
  53. package/src/lib/mdz_stream_parser_text.ts +38 -37
  54. package/src/lib/mdz_to_svelte.ts +23 -31
  55. package/src/lib/mdz_token_parser.ts +17 -20
  56. package/src/lib/svelte_preprocess_mdz.ts +34 -35
@@ -10,7 +10,7 @@ import {
10
10
  A_UPPER,
11
11
  LEFT_BRACKET,
12
12
  LEFT_PAREN,
13
- NEWLINE,
13
+ MAX_INLINE_NESTING_DEPTH,
14
14
  RIGHT_ANGLE,
15
15
  RIGHT_BRACKET,
16
16
  RIGHT_PAREN,
@@ -28,6 +28,7 @@ import {
28
28
  type TryResult,
29
29
  accumulate_text,
30
30
  alloc_id,
31
+ buffer_index_of,
31
32
  emit,
32
33
  ensure_paragraph,
33
34
  flush_text,
@@ -37,11 +38,20 @@ import {
37
38
  push_stack_entry,
38
39
  revert_above,
39
40
  } from './mdz_stream_parser_state.ts';
41
+ import {consume_delimiter_as_text} from './mdz_stream_parser_text.ts';
42
+ import {mdz_debug_work} from './mdz_debug_work.ts';
40
43
 
41
44
  // -- Links --
42
45
 
43
46
  /** @nodocs */
44
47
  export const try_link_open = (state: MdzStreamParserState): boolean => {
48
+ // nesting-depth cap: too deep to open — render `[` literal, matching the
49
+ // sync lexer's `#tokenize_markdown_link` cap (keeps the two parsers agreeing
50
+ // on where deep `[` stops nesting)
51
+ if (state.open_link_tag_depth >= MAX_INLINE_NESTING_DEPTH) {
52
+ consume_delimiter_as_text(state, '[');
53
+ return true;
54
+ }
45
55
  flush_text(state);
46
56
  ensure_paragraph(state);
47
57
 
@@ -97,65 +107,69 @@ export const try_complete_link = (state: MdzStreamParserState, link_stack_idx: n
97
107
  return true;
98
108
  }
99
109
 
100
- // found ]( — scan for )
101
- let i = bracket_pos + 2; // past ](
102
- while (i < state.buffer.length) {
103
- const c = state.buffer.charCodeAt(i);
104
- if (c === RIGHT_PAREN) {
105
- const reference = state.buffer.slice(bracket_pos + 2, i);
106
- // validate reference: non-empty and only valid path chars
107
- if (!reference.trim()) {
108
- abort_link_to_text(state, link_stack_idx, bracket_pos);
109
- return true;
110
- }
111
- let valid = true;
112
- for (let j = 0; j < reference.length; j++) {
113
- if (!is_valid_path_char(reference.charCodeAt(j))) {
114
- valid = false;
115
- break;
116
- }
117
- }
118
- if (!valid) {
119
- abort_link_to_text(state, link_stack_idx, bracket_pos);
120
- return true;
121
- }
122
- // Reject unsafe protocols (javascript:, data:, etc.) at parse time.
123
- // `is_valid_path_char` permits `:` so we have to filter explicitly.
124
- if (!mdz_is_safe_reference(reference)) {
125
- abort_link_to_text(state, link_stack_idx, bracket_pos);
126
- return true;
127
- }
128
-
129
- // success — close the link with reference
130
- flush_text(state);
131
- revert_above(state, link_stack_idx);
132
- const entry = pop_stack_entry(state);
133
- const link_type = mdz_is_url(reference) ? 'external' : 'internal';
134
- emit(state, {
135
- type: 'close',
136
- id: entry.id,
137
- end: offset(state, i + 1),
138
- reference,
139
- link_type,
140
- });
141
- state.active_text_id = null;
142
- state.pos = i + 1;
143
- state.column += i + 1 - bracket_pos;
144
- state.prev_char = RIGHT_PAREN;
110
+ // found ]( — find the reference's terminator: `)` completes, a space or
111
+ // newline aborts. Memoized searches (see `buffer_index_of`) — a link whose
112
+ // URL arrives incrementally re-enters here on every feed with `pos` still
113
+ // at the `]`, and a raw scan would re-cover the growing reference each
114
+ // time, going quadratic on long streamed URLs.
115
+ const ref_start = bracket_pos + 2;
116
+ const close_pos = buffer_index_of(state, ')', ref_start);
117
+ const space_pos = buffer_index_of(state, ' ', ref_start);
118
+ const newline_pos = buffer_index_of(state, '\n', ref_start);
119
+ let invalid_pos = space_pos;
120
+ if (newline_pos !== -1 && (invalid_pos === -1 || newline_pos < invalid_pos)) {
121
+ invalid_pos = newline_pos;
122
+ }
123
+
124
+ if (close_pos === -1) {
125
+ // didn't find ) — need more input or invalid
126
+ if (invalid_pos === -1) return false; // need more input
127
+ // found invalid char before any ) — revert
128
+ abort_link_to_text(state, link_stack_idx, bracket_pos);
129
+ return true;
130
+ }
131
+ if (invalid_pos !== -1 && invalid_pos < close_pos) {
132
+ // invalid character in URL (simplified check)
133
+ abort_link_to_text(state, link_stack_idx, bracket_pos);
134
+ return true;
135
+ }
136
+
137
+ const i = close_pos;
138
+ const reference = state.buffer.slice(ref_start, i);
139
+ // validate reference: non-empty and only valid path chars
140
+ if (!reference.trim()) {
141
+ abort_link_to_text(state, link_stack_idx, bracket_pos);
142
+ return true;
143
+ }
144
+ for (let j = 0; j < reference.length; j++) {
145
+ if (!is_valid_path_char(reference.charCodeAt(j))) {
146
+ abort_link_to_text(state, link_stack_idx, bracket_pos);
145
147
  return true;
146
148
  }
147
- if (c === NEWLINE || c === SPACE) {
148
- // invalid character in URL (simplified check)
149
- break;
150
- }
151
- i++;
149
+ }
150
+ // Reject unsafe protocols (javascript:, data:, etc.) at parse time.
151
+ // `is_valid_path_char` permits `:` so we have to filter explicitly.
152
+ if (!mdz_is_safe_reference(reference)) {
153
+ abort_link_to_text(state, link_stack_idx, bracket_pos);
154
+ return true;
152
155
  }
153
156
 
154
- // didn't find ) — need more input or invalid
155
- if (i >= state.buffer.length) return false; // need more input
156
-
157
- // found invalid char before ) — revert
158
- abort_link_to_text(state, link_stack_idx, bracket_pos);
157
+ // success — close the link with reference
158
+ flush_text(state);
159
+ revert_above(state, link_stack_idx);
160
+ const entry = pop_stack_entry(state);
161
+ const link_type = mdz_is_url(reference) ? 'external' : 'internal';
162
+ emit(state, {
163
+ type: 'close',
164
+ id: entry.id,
165
+ end: offset(state, i + 1),
166
+ reference,
167
+ link_type,
168
+ });
169
+ state.active_text_id = null;
170
+ state.pos = i + 1;
171
+ state.column += i + 1 - bracket_pos;
172
+ state.prev_char = RIGHT_PAREN;
159
173
  return true;
160
174
  };
161
175
 
@@ -176,7 +190,19 @@ export const try_tag_open = (state: MdzStreamParserState): TryResult => {
176
190
  return 'not_match';
177
191
  }
178
192
 
193
+ // nesting-depth cap: `<letter…` is a tag open, but we're too deep to nest —
194
+ // render `<` literal, matching the sync lexer's `#tokenize_tag` cap. Checked
195
+ // after the closer/hold cases above (a `</…>` closer still closes; a `<` at
196
+ // the buffer end still holds), so only genuine opens are suppressed.
197
+ if (state.open_link_tag_depth >= MAX_INLINE_NESTING_DEPTH) {
198
+ consume_delimiter_as_text(state, '<');
199
+ return 'consumed';
200
+ }
201
+
179
202
  // collect tag name
203
+ // TODO the name/whitespace scans rescan from `start` on every held retry
204
+ // of an unclosed tag's open tail — needs a hold watermark; a length cap would
205
+ // diverge from the sync parser on pathological multi-KB "tag names"
180
206
  const name_start = i;
181
207
  while (i < state.buffer.length && is_tag_name_char(state.buffer.charCodeAt(i))) {
182
208
  i++;
@@ -255,9 +281,16 @@ export const try_close_tag = (state: MdzStreamParserState): TryResult => {
255
281
  const name = state.buffer.slice(name_start, i);
256
282
  i++; // past >
257
283
 
284
+ // O(1) bail when no tag of this name is open — without it, every `</name>`
285
+ // candidate walks the whole inline stack, which grows one frame per leaked
286
+ // unclosed `<Tag>` and goes quadratic on tag-dense input (the same shape
287
+ // `open_counts` guards for the delimiter inlines)
288
+ if (!state.open_tag_counts?.get(name)) return 'not_match';
289
+
258
290
  // find matching open tag on stack
259
291
  let found_idx = -1;
260
292
  for (let j = state.stack.length - 1; j >= 0; j--) {
293
+ if (mdz_debug_work.enabled) mdz_debug_work.total++; // frames walked (mismatched-tags guard)
261
294
  const entry = state.stack[j]!;
262
295
  if (
263
296
  (entry.node_type === 'Element' || entry.node_type === 'Component') &&
@@ -418,10 +418,16 @@ export const process_list_line = (
418
418
 
419
419
  if (marker !== null && marker.empty) {
420
420
  // empty item: only at an indent exactly matching an open level of the
421
- // same marker type — anything else degrades below
422
- const level_idx = state.list_levels.findIndex(
423
- (l) => l.indent === indent && l.ordered === marker.ordered,
424
- );
421
+ // same marker type — anything else degrades below (manual loop like
422
+ // `list_continuation_target` — no per-line closure allocation)
423
+ let level_idx = -1;
424
+ for (let li = 0; li < state.list_levels.length; li++) {
425
+ const l = state.list_levels[li]!;
426
+ if (l.indent === indent && l.ordered === marker.ordered) {
427
+ level_idx = li;
428
+ break;
429
+ }
430
+ }
425
431
  if (level_idx !== -1) {
426
432
  close_list_run(state);
427
433
  while (state.list_levels.length - 1 > level_idx) {
@@ -9,7 +9,9 @@
9
9
  */
10
10
 
11
11
  import type {MdzNodeTypeContainer, MdzNodeId, MdzOpcode} from './mdz_opcodes.ts';
12
+ import type {MdzTableCellParser} from './mdz.ts';
12
13
  import {NEWLINE, has_non_whitespace, mdz_heading_id_from_text} from './mdz_helpers.ts';
14
+ import {mdz_debug_work} from './mdz_debug_work.ts';
13
15
 
14
16
  /**
15
17
  * Tri-state result for `try_*` parser handlers.
@@ -214,8 +216,9 @@ export interface MdzStreamParserState {
214
216
  * is `null` for a top-level table (column-0 rows) or the enclosing list item's
215
217
  * marker indent for a table nested as a block child (rows skip the indent and
216
218
  * a dedent to that indent ends the table, returning control to the list).
219
+ * `cell_parser` is the table's shared cell parser, rebound per row.
217
220
  */
218
- table: {id: MdzNodeId; item_indent: number | null} | null;
221
+ table: {id: MdzNodeId; item_indent: number | null; cell_parser: MdzTableCellParser} | null;
219
222
  /**
220
223
  * Whether the innermost open ListItem's first inline run is current.
221
224
  * Content then flows directly into the ListItem frame — the sync AST
@@ -243,6 +246,32 @@ export interface MdzStreamParserState {
243
246
  search_memo: Map<string, BufferSearchMemo> | null;
244
247
  /** Open-container counts for `find_open`'s O(1) "nothing open" early exit. */
245
248
  open_counts: OpenInlineCounts;
249
+ /**
250
+ * Count of open `Link`/`Element`/`Component` frames in the current run — the
251
+ * streaming analogue of the sync lexer's `#inline_depth`. Kept as an O(1)
252
+ * counter (not a stack walk, which would reintroduce the deep-stack
253
+ * quadratic `open_counts` exists to avoid) so `try_link_open`/`try_tag_open`
254
+ * can cap nesting at `MAX_INLINE_NESTING_DEPTH` — rendering a deeper `[`/`<`
255
+ * literal, matching the sync cap on pure/symmetric nesting. It counts
256
+ * **optimistic** opens, though, so it diverges from the sync `#inline_depth`
257
+ * (which the sync tag path only bumps after a closer precheck) on an
258
+ * asymmetric cap-saturating prefix — 100+ *unclosed* `<a>` before a valid
259
+ * `[x](/u)` saturates this counter and suppresses the trailing link that sync
260
+ * still forms. Adversarial-only, documented not fixed (streaming can't
261
+ * precheck closers): see the flip-at-cap case in `mdz_nesting_cap.test.ts`.
262
+ * Run-scoped for free like `open_counts`: a
263
+ * run close reverts every inline frame above it, decrementing back to zero.
264
+ */
265
+ open_link_tag_depth: number;
266
+ /**
267
+ * Per-name counts of open Element/Component frames, the tag analogue of
268
+ * `open_counts`: `try_close_tag` bails in O(1) when the closing name isn't
269
+ * open, instead of walking a stack that grows one frame per leaked unclosed
270
+ * `<Tag>`. Lazily created on the first tag open — tag-free inputs never
271
+ * allocate the map. Run-scoped like `open_counts`: run closes revert every
272
+ * inline frame above them, decrementing the counts back to zero.
273
+ */
274
+ open_tag_counts: Map<string, number> | null;
246
275
  /** Whether we're inside a heading (newline ends it). */
247
276
  in_heading: boolean;
248
277
  /** Whether we're inside an optimistic inline Code container. */
@@ -315,6 +344,8 @@ export const create_state = (): MdzStreamParserState => ({
315
344
  base_offset: 0,
316
345
  search_memo: null,
317
346
  open_counts: {Bold: 0, Italic: 0, Strikethrough: 0, Link: 0},
347
+ open_link_tag_depth: 0,
348
+ open_tag_counts: null,
318
349
  in_heading: false,
319
350
  in_code: false,
320
351
  in_paragraph: false,
@@ -362,6 +393,13 @@ export const push_stack_entry = (
362
393
  } else {
363
394
  bump_open_count(state, node_type, 1);
364
395
  }
396
+ if (tag_name !== undefined) {
397
+ const counts = (state.open_tag_counts ??= new Map());
398
+ counts.set(tag_name, (counts.get(tag_name) ?? 0) + 1);
399
+ }
400
+ // Link/Element/Component contribute to the nesting-depth cap (tag_name is
401
+ // set for Element/Component only, so `Link || tag_name` is exactly the three)
402
+ if (node_type === 'Link' || tag_name !== undefined) state.open_link_tag_depth++;
365
403
  };
366
404
 
367
405
  /**
@@ -394,6 +432,16 @@ const bump_open_count = (
394
432
  export const pop_stack_entry = (state: MdzStreamParserState): StackEntry => {
395
433
  const entry = state.stack.pop()!;
396
434
  bump_open_count(state, entry.node_type, -1);
435
+ if (entry.tag_name !== undefined) {
436
+ const counts = state.open_tag_counts!;
437
+ // drop zero-count keys so a long stream of many distinct tag names can't
438
+ // grow the map unboundedly — the `!counts.get(name)` bail in `try_close_tag`
439
+ // treats a missing key and a 0 count identically, so this is behavior-neutral
440
+ const remaining = counts.get(entry.tag_name)! - 1;
441
+ if (remaining === 0) counts.delete(entry.tag_name);
442
+ else counts.set(entry.tag_name, remaining);
443
+ }
444
+ if (entry.node_type === 'Link' || entry.tag_name !== undefined) state.open_link_tag_depth--;
397
445
  return entry;
398
446
  };
399
447
 
@@ -465,7 +513,11 @@ const memo_index_of = (
465
513
  // rescan only the appended tail, overlapping the old boundary so a
466
514
  // straddling needle isn't missed
467
515
  const rescan_from = Math.max(global_from, memo.searched_to - (needle.length - 1));
468
- const result = state.buffer.indexOf(needle, rescan_from - state.base_offset);
516
+ const local_from = rescan_from - state.base_offset;
517
+ const result = state.buffer.indexOf(needle, local_from);
518
+ if (mdz_debug_work.enabled) {
519
+ mdz_debug_work.total += (result === -1 ? state.buffer.length : result) - local_from;
520
+ }
469
521
  memo.from = global_from;
470
522
  memo.result = result === -1 ? -1 : state.base_offset + result;
471
523
  memo.searched_to = global_end;
@@ -474,6 +526,9 @@ const memo_index_of = (
474
526
  // found result already behind `from` — fall through to a fresh scan
475
527
  }
476
528
  const result = state.buffer.indexOf(needle, from);
529
+ if (mdz_debug_work.enabled) {
530
+ mdz_debug_work.total += (result === -1 ? state.buffer.length : result) - from;
531
+ }
477
532
  memo.from = global_from;
478
533
  memo.result = result === -1 ? -1 : state.base_offset + result;
479
534
  memo.searched_to = global_end;
@@ -519,8 +574,13 @@ export const emit = (state: MdzStreamParserState, op: MdzOpcode): void => {
519
574
  if (top) {
520
575
  top.has_children = true;
521
576
  // propagate non-whitespace marker to the nearest Paragraph so
522
- // whitespace-only paragraphs can be discarded at close
523
- if (op.type === 'void' || has_non_whitespace(op.content)) {
577
+ // whitespace-only paragraphs can be discarded at close — the O(1)
578
+ // no-paragraph check runs first so codeblock/table-cell emissions
579
+ // (never inside a Paragraph) skip the content scan entirely
580
+ if (
581
+ op.type === 'void' ||
582
+ (state.paragraph_stack_idx !== -1 && has_non_whitespace(op.content))
583
+ ) {
524
584
  mark_paragraph_non_whitespace(state);
525
585
  }
526
586
  }
@@ -744,6 +804,18 @@ export const revert_failed_close = (state: MdzStreamParserState, stack_idx: numb
744
804
  const frames_above = state.stack.length - 1 - stack_idx;
745
805
  state.stack.splice(stack_idx, 1);
746
806
  bump_open_count(state, entry.node_type, -1);
807
+ if (entry.tag_name !== undefined) {
808
+ const counts = state.open_tag_counts!;
809
+ // drop zero-count keys so a long stream of many distinct tag names can't
810
+ // grow the map unboundedly — the `!counts.get(name)` bail in `try_close_tag`
811
+ // treats a missing key and a 0 count identically, so this is behavior-neutral
812
+ const remaining = counts.get(entry.tag_name)! - 1;
813
+ if (remaining === 0) counts.delete(entry.tag_name);
814
+ else counts.set(entry.tag_name, remaining);
815
+ }
816
+ // only ever reverts an Italic today, but keep the depth counter honest if a
817
+ // Link/Element/Component is ever failed-closed mid-stack
818
+ if (entry.node_type === 'Link' || entry.tag_name !== undefined) state.open_link_tag_depth--;
747
819
  state.opcodes.push({
748
820
  type: 'revert',
749
821
  id: entry.id,
@@ -7,8 +7,9 @@
7
7
  * whole header emits at once. Body rows then stream one per line. Because a row
8
8
  * is always fully buffered before it emits (no within-row streaming), each
9
9
  * cell's inline content is parsed with the sync reference (`MdzTableCellParser`,
10
- * one `MdzLexer`/`MdzTokenParser` shared across the row's cells) and replayed
11
- * as opcodes — guaranteeing sync↔stream parity by construction.
10
+ * one `MdzLexer`/`MdzTokenParser` shared across the whole table's cells,
11
+ * rebound per row) and replayed as opcodes — guaranteeing sync↔stream parity
12
+ * by construction.
12
13
  *
13
14
  * @module
14
15
  */
@@ -72,7 +73,11 @@ export const try_table_start = (state: MdzStreamParserState, forced: boolean): T
72
73
  const table_start = offset(state, start);
73
74
  emit(state, {type: 'open', id: table_id, node_type: 'Table', start: table_start, align});
74
75
  push_stack_entry(state, table_id, 'Table', table_start);
75
- state.table = {id: table_id, item_indent: null};
76
+ state.table = {
77
+ id: table_id,
78
+ item_indent: null,
79
+ cell_parser: new MdzTableCellParser(state.buffer),
80
+ };
76
81
 
77
82
  emit_table_row(state, header_cells, true, start, header_end);
78
83
 
@@ -280,7 +285,11 @@ export const open_table_in_item = (
280
285
  align: head.align,
281
286
  });
282
287
  push_stack_entry(state, table_id, 'Table', table_start);
283
- state.table = {id: table_id, item_indent: marker_indent};
288
+ state.table = {
289
+ id: table_id,
290
+ item_indent: marker_indent,
291
+ cell_parser: new MdzTableCellParser(state.buffer),
292
+ };
284
293
  emit_table_row(state, head.header_cells, true, header_first, head.header_end);
285
294
  // leave the cursor on the delimiter's newline (or EOF) — the in-item body-row
286
295
  // handler reads from the line after it and hands back on this same convention
@@ -301,9 +310,11 @@ const emit_table_row = (
301
310
  const row_off = offset(state, row_start);
302
311
  emit(state, {type: 'open', id: row_id, node_type: 'TableRow', start: row_off, header});
303
312
  push_stack_entry(state, row_id, 'TableRow', row_off);
304
- // one lexer/parser shared across the row's cells (all index into the same
305
- // buffer) rather than allocated per cell
306
- const cell_parser = new MdzTableCellParser(state.buffer);
313
+ // one lexer/parser shared across the whole table rather than allocated per
314
+ // row (or per cell) — rebound per row because the buffer string changes
315
+ // across feeds; the lexer's search memo survives when it doesn't
316
+ const cell_parser = state.table!.cell_parser;
317
+ cell_parser.rebind(state.buffer);
307
318
  for (const cell of cells) {
308
319
  emit_table_cell(state, cell_parser, cell.start, cell.end);
309
320
  }
@@ -5,21 +5,15 @@
5
5
  */
6
6
 
7
7
  import {
8
- ASTERISK,
9
- BACKTICK,
10
8
  HTTPS_PREFIX_LENGTH,
11
- H_LOWER,
12
- H_UPPER,
13
- LEFT_ANGLE,
14
- LEFT_BRACKET,
15
9
  NEWLINE,
16
- PERIOD,
17
- RIGHT_BRACKET,
18
- SLASH,
19
10
  SPACE,
20
11
  TAB,
21
- TILDE,
22
- UNDERSCORE,
12
+ TEXT_CLASS_NEWLINE,
13
+ TEXT_CLASS_PATH,
14
+ TEXT_CLASS_STOP,
15
+ TEXT_CLASS_URL,
16
+ TEXT_SCAN_CLASS,
23
17
  is_word_char,
24
18
  match_url_prefix_case_insensitive,
25
19
  } from './mdz_helpers.ts';
@@ -29,6 +23,7 @@ import {
29
23
  ensure_paragraph,
30
24
  offset,
31
25
  } from './mdz_stream_parser_state.ts';
26
+ import {mdz_debug_work} from './mdz_debug_work.ts';
32
27
 
33
28
  /**
34
29
  * Consume a delimiter at the current position as literal text — the shared
@@ -60,35 +55,41 @@ export const consume_text_run = (state: MdzStreamParserState): void => {
60
55
  state.pos++;
61
56
  while (state.pos < state.buffer.length) {
62
57
  const c = state.buffer.charCodeAt(state.pos);
63
- if (
64
- c === BACKTICK ||
65
- c === ASTERISK ||
66
- c === UNDERSCORE ||
67
- c === TILDE ||
68
- c === LEFT_BRACKET ||
69
- c === RIGHT_BRACKET ||
70
- c === LEFT_ANGLE ||
71
- c === NEWLINE ||
72
- // URL/path: only break when actually at a URL or path start.
73
- // Most h/H/./slash in prose are not URLs/paths — continuing the scan
74
- // avoids the full dispatch cycle through process_loop → process_inline.
75
- // For `h`/`H` at a word boundary, also break when there isn't enough
76
- // buffer left to confirm `https://` / `http://` — process_inline's
77
- // speculator then carries the prefix match across chunk boundaries.
78
- // Scheme matching is case-insensitive (RFC 3986).
79
- ((c === H_LOWER || c === H_UPPER) &&
80
- (match_url_prefix_case_insensitive(state.buffer, state.pos) > 0 ||
81
- (state.buffer.length - state.pos < HTTPS_PREFIX_LENGTH &&
82
- !is_word_char(state.buffer.charCodeAt(state.pos - 1))))) ||
83
- ((c === SLASH || c === PERIOD) &&
84
- (state.buffer.charCodeAt(state.pos - 1) === SPACE ||
85
- state.buffer.charCodeAt(state.pos - 1) === NEWLINE ||
86
- state.buffer.charCodeAt(state.pos - 1) === TAB))
87
- ) {
88
- break;
58
+ // one table load classifies the char; zero (the common case) continues
59
+ // the run without any further checks. `TEXT_SCAN_CLASS` is shared with
60
+ // the sync lexer's `#tokenize_text` (`mdz_lexer.ts`); the dispatch bodies
61
+ // intentionally differ — that one handles `\|` escapes and list-run
62
+ // strip, this one URL speculation across chunk boundaries.
63
+ const char_class = c < 128 ? TEXT_SCAN_CLASS[c]! : 0;
64
+ if (char_class === 0) {
65
+ state.pos++;
66
+ continue;
89
67
  }
68
+ if ((char_class & (TEXT_CLASS_STOP | TEXT_CLASS_NEWLINE)) !== 0) break;
69
+ if ((char_class & TEXT_CLASS_URL) !== 0) {
70
+ // URL: only break when actually at a URL start. Most h/H in prose are
71
+ // not URLs — continuing the scan avoids the full dispatch cycle
72
+ // through process_loop → process_inline. At a word boundary, also
73
+ // break when there isn't enough buffer left to confirm `https://` /
74
+ // `http://` — process_inline's speculator then carries the prefix
75
+ // match across chunk boundaries. Scheme matching is case-insensitive
76
+ // (RFC 3986).
77
+ if (
78
+ match_url_prefix_case_insensitive(state.buffer, state.pos) > 0 ||
79
+ (state.buffer.length - state.pos < HTTPS_PREFIX_LENGTH &&
80
+ !is_word_char(state.buffer.charCodeAt(state.pos - 1)))
81
+ ) {
82
+ break;
83
+ }
84
+ } else if ((char_class & TEXT_CLASS_PATH) !== 0) {
85
+ // path (`/` or `.`): only break at a plausible path start
86
+ const prev = state.buffer.charCodeAt(state.pos - 1);
87
+ if (prev === SPACE || prev === NEWLINE || prev === TAB) break;
88
+ }
89
+ // backslash (or a non-starting path char) — ordinary text
90
90
  state.pos++;
91
91
  }
92
+ if (mdz_debug_work.enabled) mdz_debug_work.total += state.pos - start;
92
93
  accumulate_text(state, state.buffer.slice(start, state.pos), offset(state, start));
93
94
  state.prev_char = state.buffer.charCodeAt(state.pos - 1);
94
95
  state.column += state.pos - start;
@@ -13,7 +13,7 @@ import {escape_svelte_text} from '@fuzdev/fuz_util/svelte_preprocess_helpers.ts'
13
13
  import {escape_js_string} from '@fuzdev/fuz_util/string.ts';
14
14
 
15
15
  import type {MdzNode, MdzNodeTableRow} from './mdz.ts';
16
- import {mdz_resolve_relative_path, mdz_is_void_element} from './mdz_helpers.ts';
16
+ import {mdz_is_void_element, mdz_classify_link} from './mdz_helpers.ts';
17
17
 
18
18
  /**
19
19
  * Result of converting `MdzNode` arrays to Svelte markup.
@@ -131,26 +131,21 @@ export const mdz_to_svelte = (
131
131
 
132
132
  case 'Link': {
133
133
  const children_markup = render_nodes(node.children);
134
- if (node.link_type === 'internal') {
135
- if (node.reference.startsWith('.') && base) {
136
- const resolved = mdz_resolve_relative_path(node.reference, base);
137
- imports.set('resolve', {path: '$app/paths', kind: 'named'});
138
- return `<a href={resolve('${escape_js_string(resolved)}')}>${children_markup}</a>`;
139
- }
140
- if (
141
- node.reference.startsWith('#') ||
142
- node.reference.startsWith('?') ||
143
- node.reference.startsWith('.') ||
144
- // Bare references (e.g. `foo`) aren't absolute — resolve() throws on them
145
- !node.reference.startsWith('/')
146
- ) {
147
- return `<a href={'${escape_js_string(node.reference)}'}>${children_markup}</a>`;
148
- }
134
+ // safety gate + resolve-vs-raw classification shared with the runtime
135
+ // views via `mdz_classify_link`, so the three renderers can't drift
136
+ // (an unsafe `javascript:`/`data:` ref renders as plain children, no
137
+ // `<a>` — defense in depth, since `mdz_to_svelte` is a public entry
138
+ // callable with hand-built trees that never went through `mdz_parse`)
139
+ const link = mdz_classify_link(node.reference, node.link_type, base);
140
+ if (link.kind === 'unsafe') return children_markup;
141
+ if (link.kind === 'resolve') {
149
142
  imports.set('resolve', {path: '$app/paths', kind: 'named'});
150
- return `<a href={resolve('${escape_js_string(node.reference)}')}>${children_markup}</a>`;
143
+ return `<a href={resolve('${escape_js_string(link.href)}')}>${children_markup}</a>`;
144
+ }
145
+ if (link.kind === 'external') {
146
+ return `<a href={'${escape_js_string(link.href)}'} target="_blank" rel="noopener">${children_markup}</a>`;
151
147
  }
152
- // External link — matches MdzNodeView: target="_blank" rel="noopener"
153
- return `<a href={'${escape_js_string(node.reference)}'} target="_blank" rel="noopener">${children_markup}</a>`;
148
+ return `<a href={'${escape_js_string(link.href)}'}>${children_markup}</a>`;
154
149
  }
155
150
 
156
151
  case 'Paragraph':
@@ -184,19 +179,16 @@ export const mdz_to_svelte = (
184
179
  }
185
180
  return `<tr>${cells}</tr>`;
186
181
  };
182
+ // by grammar the header row is always first (and the only one) —
183
+ // read it by index and skip it in the body, mirroring the node
184
+ // views (a `.filter()` pair re-partitions every row; see
185
+ // `MdzNodeView.svelte`'s "Table rendering de-quadratified" note)
187
186
  let out = '<table>';
188
- const header_rows = node.children.filter((r) => r.header);
189
- const body_rows = node.children.filter((r) => !r.header);
190
- if (header_rows.length > 0) {
191
- out += '<thead>';
192
- for (const row of header_rows) out += render_row(row, 'th');
193
- out += '</thead>';
194
- }
195
- if (body_rows.length > 0) {
196
- out += '<tbody>';
197
- for (const row of body_rows) out += render_row(row, 'td');
198
- out += '</tbody>';
199
- }
187
+ const header_row = node.children[0]?.header ? node.children[0] : undefined;
188
+ if (header_row) out += '<thead>' + render_row(header_row, 'th') + '</thead>';
189
+ let body = '';
190
+ for (const row of node.children) if (!row.header) body += render_row(row, 'td');
191
+ if (body) out += '<tbody>' + body + '</tbody>';
200
192
  return out + '</table>';
201
193
  }
202
194
 
@@ -46,6 +46,21 @@ import {
46
46
  type MdzTokenTableCellOpen,
47
47
  } from './mdz_lexer.ts';
48
48
 
49
+ /**
50
+ * Whether a token type ends the current inline context — the block-boundary
51
+ * set shared by the delimiter-pair and link children loops (an unclosed
52
+ * inline never swallows block structure).
53
+ */
54
+ const token_is_block_boundary = (type: MdzToken['type']): boolean =>
55
+ type === 'paragraph_break' ||
56
+ type === 'heading_start' ||
57
+ type === 'hr' ||
58
+ type === 'codeblock' ||
59
+ type === 'list_open' ||
60
+ type === 'list_item_close' ||
61
+ type === 'blockquote_open' ||
62
+ type === 'table_open';
63
+
49
64
  /**
50
65
  * Builds an `MdzNode[]` tree from a lexed `MdzToken[]` stream.
51
66
  * Used by `mdz_parse`, which should be preferred for simple usage.
@@ -490,16 +505,7 @@ export class MdzTokenParser {
490
505
  this.#index++;
491
506
  return {type: node_type, children, start, end: t.end};
492
507
  }
493
- if (
494
- t.type === 'paragraph_break' ||
495
- t.type === 'heading_start' ||
496
- t.type === 'hr' ||
497
- t.type === 'codeblock' ||
498
- t.type === 'list_open' ||
499
- t.type === 'list_item_close' ||
500
- t.type === 'blockquote_open' ||
501
- t.type === 'table_open'
502
- ) {
508
+ if (token_is_block_boundary(t.type)) {
503
509
  break;
504
510
  }
505
511
  const node = this.#parse_inline();
@@ -539,16 +545,7 @@ export class MdzTokenParser {
539
545
  // No link_ref - treat as text
540
546
  return {type: 'Text', content: '[', start, end: start + 1};
541
547
  }
542
- if (
543
- t.type === 'paragraph_break' ||
544
- t.type === 'heading_start' ||
545
- t.type === 'hr' ||
546
- t.type === 'codeblock' ||
547
- t.type === 'list_open' ||
548
- t.type === 'list_item_close' ||
549
- t.type === 'blockquote_open' ||
550
- t.type === 'table_open'
551
- ) {
548
+ if (token_is_block_boundary(t.type)) {
552
549
  break;
553
550
  }
554
551
  const node = this.#parse_inline();