@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
package/dist/mdz_lexer.js CHANGED
@@ -6,7 +6,8 @@
6
6
  *
7
7
  * @module
8
8
  */
9
- import { mdz_is_url, mdz_is_safe_reference, is_letter, is_tag_name_char, is_word_char, is_valid_path_char, mdz_trim_url_punctuation, BACKTICK, ASTERISK, UNDERSCORE, TILDE, NEWLINE, HYPHEN, HASH, SPACE, LEFT_ANGLE, RIGHT_ANGLE, SLASH, PERIOD, LEFT_BRACKET, LEFT_PAREN, RIGHT_BRACKET, A_UPPER, Z_UPPER, ZERO, ONE, NINE, HR_HYPHEN_COUNT, MIN_CODEBLOCK_BACKTICKS, MAX_HEADING_LEVEL, MAX_LIST_NUMBER_DIGITS, match_url_prefix_case_insensitive, is_at_absolute_path, is_at_relative_path, is_line_whitespace, mdz_match_blockquote_prefix, mdz_blockquote_line_has_content, mdz_blockquote_strip_width, mdz_remap_segments, mdz_split_table_row, mdz_parse_table_delimiter, PIPE, BACKSLASH, } from './mdz_helpers.js';
9
+ import { mdz_is_url, mdz_is_safe_reference, is_letter, is_tag_name_char, is_word_char, is_valid_path_char, mdz_trim_url_punctuation, BACKTICK, ASTERISK, UNDERSCORE, TILDE, NEWLINE, HYPHEN, HASH, SPACE, LEFT_ANGLE, RIGHT_ANGLE, SLASH, PERIOD, LEFT_BRACKET, LEFT_PAREN, RIGHT_BRACKET, A_UPPER, Z_UPPER, ZERO, ONE, NINE, HR_HYPHEN_COUNT, MIN_CODEBLOCK_BACKTICKS, MAX_HEADING_LEVEL, MAX_INLINE_NESTING_DEPTH, MAX_LIST_NUMBER_DIGITS, match_url_prefix_case_insensitive, is_at_absolute_path, is_at_relative_path, is_line_whitespace, mdz_match_blockquote_prefix, mdz_blockquote_line_has_content, mdz_blockquote_strip_width, mdz_remap_segments, mdz_split_table_row, mdz_parse_table_delimiter, PIPE, TEXT_CLASS_BACKSLASH, TEXT_CLASS_NEWLINE, TEXT_CLASS_STOP, TEXT_CLASS_URL, TEXT_SCAN_CLASS, } from './mdz_helpers.js';
10
+ import { mdz_debug_work } from './mdz_debug_work.js';
10
11
  //
11
12
  // Lexer
12
13
  //
@@ -39,9 +40,67 @@ export class MdzLexer {
39
40
  * and `\|` unescapes to a literal pipe.
40
41
  */
41
42
  #in_table_cell = false;
43
+ /**
44
+ * Start offsets of markdown-link opens (`[`) that definitively failed — so
45
+ * a re-lex from before the `[` short-circuits to literal text instead of
46
+ * re-attempting (and re-descending into) the same failing link. Without it,
47
+ * the revert-and-re-tokenize failure path is exponential on nested `[`
48
+ * (a ~30-byte `[`×n input hangs). A link's outcome (does a valid `](ref)`
49
+ * follow?) is independent of `#max_search_index`, so `start` alone keys it.
50
+ * The outcome is a pure function of the immutable text, so the memo is valid
51
+ * for the whole lex and across table-cell ranges; cleared on `rebind`.
52
+ */
53
+ #failed_link_starts = new Set();
54
+ /**
55
+ * The tag analogue of `#failed_link_starts`, keyed `${start}:${bound}`. A
56
+ * tag's closer search respects `#max_search_index`, so the same `start` can
57
+ * fail under a narrow bound yet succeed under the wider one a re-lex uses —
58
+ * the bound is part of the key.
59
+ */
60
+ #failed_tag = new Set();
61
+ /**
62
+ * Current link/tag nesting depth — the number of open
63
+ * `link_text_open`/`tag_open` containers being recursed into. Bumped around
64
+ * `#tokenize_markdown_link`/`#tokenize_tag`'s children loops (not by
65
+ * delimiters, which can't nest deeply — see `MAX_INLINE_NESTING_DEPTH`). At
66
+ * the cap a further `[`/`<` renders literal instead of opening, bounding
67
+ * recursion depth (no stack overflow) and, with the failure memos, the
68
+ * revert re-scan (linear, not quadratic) on adversarial nesting.
69
+ */
70
+ #inline_depth = 0;
71
+ /**
72
+ * Start offsets of `[`/`<` openers suppressed by the nesting-depth cap
73
+ * (`#inline_depth >= MAX_INLINE_NESTING_DEPTH`) — position-keyed like
74
+ * `#failed_link_starts` so a re-lex from a shallower depth short-circuits to
75
+ * literal text rather than re-opening the construct (which would reintroduce
76
+ * the quadratic the cap removes). A suppression is bound-independent, so
77
+ * `start` alone keys it; cleared on `rebind`.
78
+ */
79
+ #depth_capped = new Set();
42
80
  constructor(text) {
43
81
  this.#text = text;
44
82
  }
83
+ /**
84
+ * Rebind to a new text, so one lexer instance can be reused across inputs —
85
+ * the streaming table path reuses one across a table's rows via
86
+ * `MdzTableCellParser` instead of allocating a lexer (and its memo `Map`)
87
+ * per row. The search memos survive when the text is identical (they're
88
+ * valid over the immutable text — the common case for rows parsed from one
89
+ * buffered chunk) and clear otherwise. Per-parse cursor state is reset by
90
+ * `lex_table_cell` itself.
91
+ *
92
+ * @nodocs
93
+ */
94
+ rebind(text) {
95
+ if (this.#text === text)
96
+ return;
97
+ this.#text = text;
98
+ this.#search_memo.clear();
99
+ this.#break_memo = null;
100
+ this.#failed_link_starts.clear();
101
+ this.#failed_tag.clear();
102
+ this.#depth_capped.clear();
103
+ }
45
104
  /**
46
105
  * Memoized `String#indexOf` over the immutable text.
47
106
  *
@@ -58,6 +117,10 @@ export class MdzLexer {
58
117
  return memo.result;
59
118
  }
60
119
  const result = this.#text.indexOf(needle, from);
120
+ if (mdz_debug_work.enabled) {
121
+ // scan span: `indexOf` examined [from, result] (found) or [from, EOF)
122
+ mdz_debug_work.total += (result === -1 ? this.#text.length : result) - from;
123
+ }
61
124
  if (memo === undefined) {
62
125
  this.#search_memo.set(needle, { from, result });
63
126
  }
@@ -110,6 +173,9 @@ export class MdzLexer {
110
173
  */
111
174
  lex_table_cell(cell_start, cell_end) {
112
175
  this.#tokens = [];
176
+ // depth is balanced back to 0 per cell, but pin it so a future early-return
177
+ // between an `#inline_depth++` and its decrement can't leak into the next cell
178
+ this.#inline_depth = 0;
113
179
  this.#tokenize_cell_inline(cell_start, cell_end);
114
180
  return this.#tokens;
115
181
  }
@@ -1194,12 +1260,30 @@ export class MdzLexer {
1194
1260
  }
1195
1261
  #tokenize_markdown_link() {
1196
1262
  const start = this.#index;
1263
+ // a link open at this offset already failed, or was suppressed by the
1264
+ // nesting cap — emit `[` as text instead of re-descending into the same
1265
+ // failing children (exponential on nested `[`); the short-circuit
1266
+ // reproduces the revert's exact net effect (`[` text, cursor at start + 1)
1267
+ if (this.#failed_link_starts.has(start) || this.#depth_capped.has(start)) {
1268
+ this.#index = start + 1;
1269
+ this.#emit_text('[', start);
1270
+ return;
1271
+ }
1272
+ // nesting-depth cap: too deep to open — render literal and record the
1273
+ // suppression so a shallower re-lex short-circuits here too
1274
+ if (this.#inline_depth >= MAX_INLINE_NESTING_DEPTH) {
1275
+ this.#depth_capped.add(start);
1276
+ this.#index = start + 1;
1277
+ this.#emit_text('[', start);
1278
+ return;
1279
+ }
1197
1280
  this.#index++; // consume [ (dispatch guarantees it)
1198
1281
  // Emit link_text_open
1199
1282
  this.#tokens.push({ type: 'link_text_open', start, end: this.#index });
1200
1283
  // Tokenize children until ] — only `]` delimits link text; a bare `)`
1201
1284
  // is ordinary content (matching the streaming parser, which has no `)`
1202
1285
  // dispatch case — the reference scan finds its own `)` after `](`)
1286
+ this.#inline_depth++;
1203
1287
  while (this.#index < this.#text.length) {
1204
1288
  if (this.#text.charCodeAt(this.#index) === RIGHT_BRACKET)
1205
1289
  break;
@@ -1207,6 +1291,7 @@ export class MdzLexer {
1207
1291
  break;
1208
1292
  this.#tokenize_inline();
1209
1293
  }
1294
+ this.#inline_depth--;
1210
1295
  // Check for ]
1211
1296
  if (this.#index >= this.#text.length || this.#text.charCodeAt(this.#index) !== RIGHT_BRACKET) {
1212
1297
  // Revert - remove link_text_open and all children tokens added
@@ -1263,6 +1348,13 @@ export class MdzLexer {
1263
1348
  });
1264
1349
  }
1265
1350
  #revert_tokens_from_link_open(start) {
1351
+ // record the failure so a re-lex from before this `[` short-circuits.
1352
+ // Keyed by `start` alone — unlike `#failed_tag`'s `start:bound` — because a
1353
+ // link's outcome is bound-independent: the `]`-terminator scan (the children
1354
+ // loop) runs to `text.length` and never consults `#max_search_index`, so a
1355
+ // wider-bound re-lex can't move it. The bound reaches nested tags inside the
1356
+ // link text, but re-attempting those wider never flips the link's net result.
1357
+ this.#failed_link_starts.add(start);
1266
1358
  // Find and remove link_text_open and all tokens after it
1267
1359
  let open_idx = -1;
1268
1360
  for (let i = this.#tokens.length - 1; i >= 0; i--) {
@@ -1279,6 +1371,30 @@ export class MdzLexer {
1279
1371
  }
1280
1372
  #tokenize_tag() {
1281
1373
  const start = this.#index;
1374
+ // short-circuit a re-attempt of a tag open that already failed at this
1375
+ // offset+bound — re-descending into the same failing children is
1376
+ // exponential on nested same-name tags. Keyed by
1377
+ // `#max_search_index` (captured as `bound`) because the closer search is
1378
+ // bound-sensitive, so a wider-bound re-lex can legitimately succeed. The
1379
+ // key string is built only when the memo is non-empty (adversarial
1380
+ // input); prose never allocates it. The short-circuit reproduces a
1381
+ // revert's exact net effect (`<` text, cursor at start + 1).
1382
+ const bound = this.#max_search_index;
1383
+ if (this.#depth_capped.has(start) ||
1384
+ (this.#failed_tag.size !== 0 && this.#failed_tag.has(`${start}:${bound}`))) {
1385
+ this.#index = start + 1;
1386
+ this.#emit_text('<', start);
1387
+ return;
1388
+ }
1389
+ // nesting-depth cap: too deep to open a tag — render literal and record
1390
+ // the suppression (position-keyed) so a shallower re-lex short-circuits
1391
+ // here too, keeping the revert re-scan linear
1392
+ if (this.#inline_depth >= MAX_INLINE_NESTING_DEPTH) {
1393
+ this.#depth_capped.add(start);
1394
+ this.#index = start + 1;
1395
+ this.#emit_text('<', start);
1396
+ return;
1397
+ }
1282
1398
  this.#index++; // consume <
1283
1399
  // Tag name must start with a letter
1284
1400
  if (this.#index >= this.#text.length || !is_letter(this.#text.charCodeAt(this.#index))) {
@@ -1315,6 +1431,7 @@ export class MdzLexer {
1315
1431
  }
1316
1432
  // Check for >
1317
1433
  if (this.#index >= this.#text.length || this.#text.charCodeAt(this.#index) !== RIGHT_ANGLE) {
1434
+ this.#failed_tag.add(`${start}:${bound}`);
1318
1435
  this.#index = start + 1;
1319
1436
  this.#emit_text('<', start);
1320
1437
  return;
@@ -1332,11 +1449,13 @@ export class MdzLexer {
1332
1449
  const search_limit = Math.min(this.#max_search_index, this.#text.length);
1333
1450
  const closing_tag_pos = this.#index_of(closing_tag, this.#index);
1334
1451
  if (closing_tag_pos === -1 || closing_tag_pos > search_limit) {
1452
+ this.#failed_tag.add(`${start}:${bound}`);
1335
1453
  this.#index = start + 1;
1336
1454
  this.#emit_text('<', start);
1337
1455
  return;
1338
1456
  }
1339
1457
  if (this.#has_paragraph_break_between(this.#index, closing_tag_pos)) {
1458
+ this.#failed_tag.add(`${start}:${bound}`);
1340
1459
  this.#index = start + 1;
1341
1460
  this.#emit_text('<', start);
1342
1461
  return;
@@ -1350,8 +1469,10 @@ export class MdzLexer {
1350
1469
  // the code span). Recomputed per iteration because a nested same-name
1351
1470
  // tag legitimately consumes the nearest closer (`<b>x<b>y</b></b>`).
1352
1471
  const saved_max = this.#max_search_index;
1472
+ this.#inline_depth++;
1353
1473
  while (this.#index < this.#text.length) {
1354
1474
  if (this.#match(closing_tag)) {
1475
+ this.#inline_depth--;
1355
1476
  this.#max_search_index = saved_max;
1356
1477
  const close_start = this.#index;
1357
1478
  this.#index += closing_tag.length;
@@ -1372,11 +1493,13 @@ export class MdzLexer {
1372
1493
  this.#max_search_index = next_close;
1373
1494
  this.#tokenize_inline();
1374
1495
  }
1496
+ this.#inline_depth--;
1375
1497
  // No valid closer left — revert: drop the open token and all consumed
1376
1498
  // children tokens, emit `<` as text, and re-lex from the next char
1377
1499
  // (the `#revert_tokens_from_link_open` pattern; the old fallthrough
1378
1500
  // left a dangling `tag_open` whose parser fallback discarded content)
1379
1501
  this.#max_search_index = saved_max;
1502
+ this.#failed_tag.add(`${start}:${bound}`);
1380
1503
  this.#tokens.splice(open_token_index);
1381
1504
  this.#index = start + 1;
1382
1505
  this.#emit_text('<', start);
@@ -1400,15 +1523,30 @@ export class MdzLexer {
1400
1523
  // content: parts accumulate around each stripped region
1401
1524
  let parts = null;
1402
1525
  let part_start = start;
1403
- while (this.#index < this.#text.length) {
1526
+ // in a table cell the trimmed cell end is a hard stop (no newline to
1527
+ // halt on mid-line) — hoisted to the loop bound
1528
+ const scan_end = this.#in_table_cell
1529
+ ? Math.min(this.#max_search_index, this.#text.length)
1530
+ : this.#text.length;
1531
+ while (this.#index < scan_end) {
1404
1532
  const char_code = this.#text.charCodeAt(this.#index);
1405
- // in a table cell: the trimmed cell end is a hard stop (no newline to
1406
- // halt on mid-line), and `\|` is a literal pipe (the only mdz escape,
1407
- // scoped here)
1408
- if (this.#in_table_cell) {
1409
- if (this.#index >= this.#max_search_index)
1410
- break;
1411
- if (char_code === BACKSLASH &&
1533
+ // one table load classifies the char; zero (the common case — the
1534
+ // char can't end or interrupt the run) skips the dispatch below.
1535
+ // `TEXT_SCAN_CLASS` is shared with the streaming scanner
1536
+ // (`consume_text_run` in `mdz_stream_parser_text.ts`); the two dispatch
1537
+ // bodies intentionally differ around it — this one handles `\|` escapes
1538
+ // and list-run strip, that one URL speculation across chunk boundaries.
1539
+ const char_class = char_code < 128 ? TEXT_SCAN_CLASS[char_code] : 0;
1540
+ if (char_class === 0) {
1541
+ this.#index++;
1542
+ continue;
1543
+ }
1544
+ // Stop at special characters
1545
+ if ((char_class & TEXT_CLASS_STOP) !== 0)
1546
+ break;
1547
+ if ((char_class & TEXT_CLASS_BACKSLASH) !== 0) {
1548
+ // `\|` is a literal pipe — the only mdz escape, scoped to table cells
1549
+ if (this.#in_table_cell &&
1412
1550
  this.#index + 1 < this.#max_search_index &&
1413
1551
  this.#text.charCodeAt(this.#index + 1) === PIPE) {
1414
1552
  (parts ??= []).push(this.#text.slice(part_start, this.#index));
@@ -1417,18 +1555,10 @@ export class MdzLexer {
1417
1555
  part_start = this.#index;
1418
1556
  continue;
1419
1557
  }
1558
+ this.#index++;
1559
+ continue;
1420
1560
  }
1421
- // Stop at special characters
1422
- if (char_code === BACKTICK ||
1423
- char_code === ASTERISK ||
1424
- char_code === UNDERSCORE ||
1425
- char_code === TILDE ||
1426
- char_code === LEFT_BRACKET ||
1427
- char_code === RIGHT_BRACKET ||
1428
- char_code === LEFT_ANGLE) {
1429
- break;
1430
- }
1431
- if (char_code === NEWLINE) {
1561
+ if ((char_class & TEXT_CLASS_NEWLINE) !== 0) {
1432
1562
  if (this.#list_run_strip) {
1433
1563
  if (this.#index >= this.#max_search_index)
1434
1564
  break; // the run's terminator
@@ -1465,15 +1595,25 @@ export class MdzLexer {
1465
1595
  break;
1466
1596
  }
1467
1597
  }
1598
+ this.#index++; // single newline — ordinary text
1599
+ continue;
1600
+ }
1601
+ // URL or internal path mid-text (class guard avoids probing every char)
1602
+ if ((char_class & TEXT_CLASS_URL) !== 0) {
1603
+ if (this.#is_at_url())
1604
+ break;
1605
+ }
1606
+ else if (char_code === SLASH) {
1607
+ if (is_at_absolute_path(this.#text, this.#index))
1608
+ break;
1468
1609
  }
1469
- // Check for URL or internal path mid-text (char code guard avoids startsWith on every char)
1470
- if (((char_code === 104 /* h */ || char_code === 72) /* H */ && this.#is_at_url()) ||
1471
- (char_code === SLASH && is_at_absolute_path(this.#text, this.#index)) ||
1472
- (char_code === PERIOD && is_at_relative_path(this.#text, this.#index))) {
1610
+ else if (is_at_relative_path(this.#text, this.#index)) {
1473
1611
  break;
1474
1612
  }
1475
1613
  this.#index++;
1476
1614
  }
1615
+ if (mdz_debug_work.enabled)
1616
+ mdz_debug_work.total += this.#index - start;
1477
1617
  // Ensure we always consume at least one character
1478
1618
  if (this.#index === start && this.#index < this.#text.length) {
1479
1619
  this.#index++;
@@ -1 +1 @@
1
- {"version":3,"file":"mdz_stream_parser_inline.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/mdz_stream_parser_inline.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAYH,OAAO,EACN,KAAK,oBAAoB,EAYzB,MAAM,8BAA8B,CAAC;AA6GtC;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAChC,OAAO,oBAAoB,EAC3B,WAAW,IAAI,GAAG,IAAI,EACtB,WAAW,MAAM,GAAG,eAAe,EACnC,gBAAc,KACZ,OA2BF,CAAC;AAIF;;;;;GAKG;AACH,eAAO,MAAM,UAAU,GAAI,OAAO,oBAAoB,EAAE,gBAAc,KAAG,OAyCxE,CAAC;AAIF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,yBAAyB,GAAI,OAAO,oBAAoB,KAAG,OAOvE,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,oBAAoB,EAAE,WAAW,MAAM,KAAG,OAuBhF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,oBAAoB,GAChC,OAAO,oBAAoB,EAC3B,gBAAc,KACZ,WAAW,GAAG,UAAU,GAAG,SAmB7B,CAAC;AAIF,cAAc;AACd,eAAO,MAAM,QAAQ,GAAI,OAAO,oBAAoB,EAAE,gBAAc,KAAG,OAoFtE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,oBAAoB,EAAE,MAAM,MAAM,KAAG,MAyB7E,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,8BAA8B,GAAI,OAAO,oBAAoB,KAAG,OAO5E,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,OAAO,oBAAoB,KAAG,IA2ClE,CAAC"}
1
+ {"version":3,"file":"mdz_stream_parser_inline.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/mdz_stream_parser_inline.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAYH,OAAO,EACN,KAAK,oBAAoB,EAYzB,MAAM,8BAA8B,CAAC;AA6GtC;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAChC,OAAO,oBAAoB,EAC3B,WAAW,IAAI,GAAG,IAAI,EACtB,WAAW,MAAM,GAAG,eAAe,EACnC,gBAAc,KACZ,OA2BF,CAAC;AAIF;;;;;GAKG;AACH,eAAO,MAAM,UAAU,GAAI,OAAO,oBAAoB,EAAE,gBAAc,KAAG,OAyCxE,CAAC;AAIF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,yBAAyB,GAAI,OAAO,oBAAoB,KAAG,OAOvE,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,oBAAoB,EAAE,WAAW,MAAM,KAAG,OAuBhF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,oBAAoB,GAChC,OAAO,oBAAoB,EAC3B,gBAAc,KACZ,WAAW,GAAG,UAAU,GAAG,SAmB7B,CAAC;AAIF,cAAc;AACd,eAAO,MAAM,QAAQ,GAAI,OAAO,oBAAoB,EAAE,gBAAc,KAAG,OAwFtE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,oBAAoB,EAAE,MAAM,MAAM,KAAG,MAyB7E,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,8BAA8B,GAAI,OAAO,oBAAoB,KAAG,OAO5E,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,OAAO,oBAAoB,KAAG,IA2ClE,CAAC"}
@@ -297,15 +297,20 @@ export const scan_closer_boundary = (state, forced = false) => {
297
297
  /** @nodocs */
298
298
  export const try_code = (state, forced = false) => {
299
299
  const start = state.pos;
300
- let i = start + 1; // past opening backtick
300
+ const search_start = start + 1; // past opening backtick
301
301
  // compute search boundary: don't scan past open formatting delimiters
302
- const search_limit = code_search_limit(state, start + 1);
303
- // scan for closing backtick (must be before newline and search boundary)
304
- while (i < state.buffer.length && i < search_limit) {
305
- const c = state.buffer.charCodeAt(i);
306
- if (c === BACKTICK) {
302
+ const search_limit = code_search_limit(state, search_start);
303
+ // find the first closing backtick or newline (memoized — a candidate that
304
+ // holds under open formatting re-enters here on every feed, and a raw scan
305
+ // would re-cover the same growing region each time, going quadratic on a
306
+ // single long line)
307
+ const close_pos = buffer_index_of(state, '`', search_start);
308
+ const newline_pos = buffer_index_of(state, '\n', search_start);
309
+ const terminator = close_pos !== -1 && (newline_pos === -1 || close_pos < newline_pos) ? close_pos : newline_pos;
310
+ if (terminator !== -1 && terminator < search_limit) {
311
+ if (terminator === close_pos) {
307
312
  // found close
308
- const content = state.buffer.slice(start + 1, i);
313
+ const content = state.buffer.slice(search_start, terminator);
309
314
  if (content.length === 0) {
310
315
  // empty code — treat as text
311
316
  consume_delimiter_as_text(state, '``');
@@ -320,20 +325,17 @@ export const try_code = (state, forced = false) => {
320
325
  content,
321
326
  text_type: 'Code',
322
327
  start: offset(state, start),
323
- end: offset(state, i + 1),
328
+ end: offset(state, terminator + 1),
324
329
  });
325
330
  state.active_text_id = null;
326
- state.pos = i + 1;
327
- state.column += i + 1 - start;
331
+ state.pos = terminator + 1;
332
+ state.column += terminator + 1 - start;
328
333
  state.prev_char = BACKTICK;
329
334
  return true;
330
335
  }
331
- if (c === NEWLINE) {
332
- // inline code can't span lines — treat opening backtick as text
333
- consume_delimiter_as_text(state, '`');
334
- return true;
335
- }
336
- i++;
336
+ // newline before any close — inline code can't span lines
337
+ consume_delimiter_as_text(state, '`');
338
+ return true;
337
339
  }
338
340
  // hit search limit (open formatting delimiter boundary) — treat backtick as
339
341
  // text. This is the documented backtick-adjacent divergence point: when the
@@ -341,7 +343,7 @@ export const try_code = (state, forced = false) => {
341
343
  // processed), the one-shot parse rejects that italic and scans unbounded —
342
344
  // see the `MdzStreamParser` TSDoc and the parity suite's expected-divergence
343
345
  // battery
344
- if (i < state.buffer.length && i >= search_limit) {
346
+ if (search_limit < state.buffer.length) {
345
347
  consume_delimiter_as_text(state, '`');
346
348
  return true;
347
349
  }
@@ -1 +1 @@
1
- {"version":3,"file":"mdz_stream_parser_link.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/mdz_stream_parser_link.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAoBH,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,SAAS,EAWd,MAAM,8BAA8B,CAAC;AAItC,cAAc;AACd,eAAO,MAAM,aAAa,GAAI,OAAO,oBAAoB,KAAG,OAa3D,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,GAC9B,OAAO,oBAAoB,EAC3B,gBAAgB,MAAM,EACtB,aAAa,MAAM,KACjB,IAUF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,oBAAoB,EAAE,gBAAgB,MAAM,KAAG,OAwEvF,CAAC;AAIF,cAAc;AACd,eAAO,MAAM,YAAY,GAAI,OAAO,oBAAoB,KAAG,SAyE1D,CAAC;AAEF,cAAc;AACd,eAAO,MAAM,aAAa,GAAI,OAAO,oBAAoB,KAAG,SA0C3D,CAAC"}
1
+ {"version":3,"file":"mdz_stream_parser_link.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/mdz_stream_parser_link.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAoBH,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,SAAS,EAYd,MAAM,8BAA8B,CAAC;AAMtC,cAAc;AACd,eAAO,MAAM,aAAa,GAAI,OAAO,oBAAoB,KAAG,OAoB3D,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,GAC9B,OAAO,oBAAoB,EAC3B,gBAAgB,MAAM,EACtB,aAAa,MAAM,KACjB,IAUF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,oBAAoB,EAAE,gBAAgB,MAAM,KAAG,OA4EvF,CAAC;AAIF,cAAc;AACd,eAAO,MAAM,YAAY,GAAI,OAAO,oBAAoB,KAAG,SAqF1D,CAAC;AAEF,cAAc;AACd,eAAO,MAAM,aAAa,GAAI,OAAO,oBAAoB,KAAG,SAiD3D,CAAC"}
@@ -4,11 +4,20 @@
4
4
  *
5
5
  * @module
6
6
  */
7
- import { A_UPPER, LEFT_BRACKET, LEFT_PAREN, NEWLINE, RIGHT_ANGLE, RIGHT_BRACKET, RIGHT_PAREN, SLASH, SPACE, Z_UPPER, is_letter, is_tag_name_char, is_valid_path_char, mdz_is_safe_reference, mdz_is_url, } from './mdz_helpers.js';
8
- import { accumulate_text, alloc_id, emit, ensure_paragraph, flush_text, is_run_boundary, offset, pop_stack_entry, push_stack_entry, revert_above, } from './mdz_stream_parser_state.js';
7
+ import { A_UPPER, LEFT_BRACKET, LEFT_PAREN, MAX_INLINE_NESTING_DEPTH, RIGHT_ANGLE, RIGHT_BRACKET, RIGHT_PAREN, SLASH, SPACE, Z_UPPER, is_letter, is_tag_name_char, is_valid_path_char, mdz_is_safe_reference, mdz_is_url, } from './mdz_helpers.js';
8
+ import { accumulate_text, alloc_id, buffer_index_of, emit, ensure_paragraph, flush_text, is_run_boundary, offset, pop_stack_entry, push_stack_entry, revert_above, } from './mdz_stream_parser_state.js';
9
+ import { consume_delimiter_as_text } from './mdz_stream_parser_text.js';
10
+ import { mdz_debug_work } from './mdz_debug_work.js';
9
11
  // -- Links --
10
12
  /** @nodocs */
11
13
  export const try_link_open = (state) => {
14
+ // nesting-depth cap: too deep to open — render `[` literal, matching the
15
+ // sync lexer's `#tokenize_markdown_link` cap (keeps the two parsers agreeing
16
+ // on where deep `[` stops nesting)
17
+ if (state.open_link_tag_depth >= MAX_INLINE_NESTING_DEPTH) {
18
+ consume_delimiter_as_text(state, '[');
19
+ return true;
20
+ }
12
21
  flush_text(state);
13
22
  ensure_paragraph(state);
14
23
  const id = alloc_id(state);
@@ -55,63 +64,67 @@ export const try_complete_link = (state, link_stack_idx) => {
55
64
  abort_link_to_text(state, link_stack_idx, bracket_pos);
56
65
  return true;
57
66
  }
58
- // found ]( — scan for )
59
- let i = bracket_pos + 2; // past ](
60
- while (i < state.buffer.length) {
61
- const c = state.buffer.charCodeAt(i);
62
- if (c === RIGHT_PAREN) {
63
- const reference = state.buffer.slice(bracket_pos + 2, i);
64
- // validate reference: non-empty and only valid path chars
65
- if (!reference.trim()) {
66
- abort_link_to_text(state, link_stack_idx, bracket_pos);
67
- return true;
68
- }
69
- let valid = true;
70
- for (let j = 0; j < reference.length; j++) {
71
- if (!is_valid_path_char(reference.charCodeAt(j))) {
72
- valid = false;
73
- break;
74
- }
75
- }
76
- if (!valid) {
77
- abort_link_to_text(state, link_stack_idx, bracket_pos);
78
- return true;
79
- }
80
- // Reject unsafe protocols (javascript:, data:, etc.) at parse time.
81
- // `is_valid_path_char` permits `:` so we have to filter explicitly.
82
- if (!mdz_is_safe_reference(reference)) {
83
- abort_link_to_text(state, link_stack_idx, bracket_pos);
84
- return true;
85
- }
86
- // success — close the link with reference
87
- flush_text(state);
88
- revert_above(state, link_stack_idx);
89
- const entry = pop_stack_entry(state);
90
- const link_type = mdz_is_url(reference) ? 'external' : 'internal';
91
- emit(state, {
92
- type: 'close',
93
- id: entry.id,
94
- end: offset(state, i + 1),
95
- reference,
96
- link_type,
97
- });
98
- state.active_text_id = null;
99
- state.pos = i + 1;
100
- state.column += i + 1 - bracket_pos;
101
- state.prev_char = RIGHT_PAREN;
67
+ // found ]( — find the reference's terminator: `)` completes, a space or
68
+ // newline aborts. Memoized searches (see `buffer_index_of`) — a link whose
69
+ // URL arrives incrementally re-enters here on every feed with `pos` still
70
+ // at the `]`, and a raw scan would re-cover the growing reference each
71
+ // time, going quadratic on long streamed URLs.
72
+ const ref_start = bracket_pos + 2;
73
+ const close_pos = buffer_index_of(state, ')', ref_start);
74
+ const space_pos = buffer_index_of(state, ' ', ref_start);
75
+ const newline_pos = buffer_index_of(state, '\n', ref_start);
76
+ let invalid_pos = space_pos;
77
+ if (newline_pos !== -1 && (invalid_pos === -1 || newline_pos < invalid_pos)) {
78
+ invalid_pos = newline_pos;
79
+ }
80
+ if (close_pos === -1) {
81
+ // didn't find ) — need more input or invalid
82
+ if (invalid_pos === -1)
83
+ return false; // need more input
84
+ // found invalid char before any ) — revert
85
+ abort_link_to_text(state, link_stack_idx, bracket_pos);
86
+ return true;
87
+ }
88
+ if (invalid_pos !== -1 && invalid_pos < close_pos) {
89
+ // invalid character in URL (simplified check)
90
+ abort_link_to_text(state, link_stack_idx, bracket_pos);
91
+ return true;
92
+ }
93
+ const i = close_pos;
94
+ const reference = state.buffer.slice(ref_start, i);
95
+ // validate reference: non-empty and only valid path chars
96
+ if (!reference.trim()) {
97
+ abort_link_to_text(state, link_stack_idx, bracket_pos);
98
+ return true;
99
+ }
100
+ for (let j = 0; j < reference.length; j++) {
101
+ if (!is_valid_path_char(reference.charCodeAt(j))) {
102
+ abort_link_to_text(state, link_stack_idx, bracket_pos);
102
103
  return true;
103
104
  }
104
- if (c === NEWLINE || c === SPACE) {
105
- // invalid character in URL (simplified check)
106
- break;
107
- }
108
- i++;
109
105
  }
110
- // didn't find ) — need more input or invalid
111
- if (i >= state.buffer.length)
112
- return false; // need more input
113
- // found invalid char before ) — revert
114
- abort_link_to_text(state, link_stack_idx, bracket_pos);
106
+ // Reject unsafe protocols (javascript:, data:, etc.) at parse time.
107
+ // `is_valid_path_char` permits `:` so we have to filter explicitly.
108
+ if (!mdz_is_safe_reference(reference)) {
109
+ abort_link_to_text(state, link_stack_idx, bracket_pos);
110
+ return true;
111
+ }
112
+ // success — close the link with reference
113
+ flush_text(state);
114
+ revert_above(state, link_stack_idx);
115
+ const entry = pop_stack_entry(state);
116
+ const link_type = mdz_is_url(reference) ? 'external' : 'internal';
117
+ emit(state, {
118
+ type: 'close',
119
+ id: entry.id,
120
+ end: offset(state, i + 1),
121
+ reference,
122
+ link_type,
123
+ });
124
+ state.active_text_id = null;
125
+ state.pos = i + 1;
126
+ state.column += i + 1 - bracket_pos;
127
+ state.prev_char = RIGHT_PAREN;
115
128
  return true;
116
129
  };
117
130
  // -- Tags --
@@ -129,7 +142,18 @@ export const try_tag_open = (state) => {
129
142
  if (!is_letter(state.buffer.charCodeAt(i))) {
130
143
  return 'not_match';
131
144
  }
145
+ // nesting-depth cap: `<letter…` is a tag open, but we're too deep to nest —
146
+ // render `<` literal, matching the sync lexer's `#tokenize_tag` cap. Checked
147
+ // after the closer/hold cases above (a `</…>` closer still closes; a `<` at
148
+ // the buffer end still holds), so only genuine opens are suppressed.
149
+ if (state.open_link_tag_depth >= MAX_INLINE_NESTING_DEPTH) {
150
+ consume_delimiter_as_text(state, '<');
151
+ return 'consumed';
152
+ }
132
153
  // collect tag name
154
+ // TODO the name/whitespace scans rescan from `start` on every held retry
155
+ // of an unclosed tag's open tail — needs a hold watermark; a length cap would
156
+ // diverge from the sync parser on pathological multi-KB "tag names"
133
157
  const name_start = i;
134
158
  while (i < state.buffer.length && is_tag_name_char(state.buffer.charCodeAt(i))) {
135
159
  i++;
@@ -201,9 +225,17 @@ export const try_close_tag = (state) => {
201
225
  return 'not_match';
202
226
  const name = state.buffer.slice(name_start, i);
203
227
  i++; // past >
228
+ // O(1) bail when no tag of this name is open — without it, every `</name>`
229
+ // candidate walks the whole inline stack, which grows one frame per leaked
230
+ // unclosed `<Tag>` and goes quadratic on tag-dense input (the same shape
231
+ // `open_counts` guards for the delimiter inlines)
232
+ if (!state.open_tag_counts?.get(name))
233
+ return 'not_match';
204
234
  // find matching open tag on stack
205
235
  let found_idx = -1;
206
236
  for (let j = state.stack.length - 1; j >= 0; j--) {
237
+ if (mdz_debug_work.enabled)
238
+ mdz_debug_work.total++; // frames walked (mismatched-tags guard)
207
239
  const entry = state.stack[j];
208
240
  if ((entry.node_type === 'Element' || entry.node_type === 'Component') &&
209
241
  entry.tag_name === name) {
@@ -1 +1 @@
1
- {"version":3,"file":"mdz_stream_parser_list.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/mdz_stream_parser_list.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAmBH,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,SAAS,EAad,MAAM,8BAA8B,CAAC;AAEtC;;;;;;;GAOG;AACH,MAAM,WAAW,mBAAmB;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,OAAO,CAAC;CACf;AAoBD;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,GAC7B,OAAO,oBAAoB,EAC3B,GAAG,MAAM,EACT,QAAQ,OAAO,KACb,mBAAmB,GAAG,SAAS,GAAG,IAsDpC,CAAC;AAEF;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACpC,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,qDAAqD;IACrD,aAAa,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAC9B,OAAO,oBAAoB,EAC3B,GAAG,MAAM,EACT,QAAQ,OAAO,KACb,oBAAoB,GAAG,SAAS,GAAG,IAuBrC,CAAC;AAgGF;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,GAAI,OAAO,oBAAoB,EAAE,QAAQ,OAAO,KAAG,SAY7E,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,iBAAiB,GAC7B,OAAO,oBAAoB,EAC3B,QAAQ,OAAO,KACb,OAAO,GAAG,YA+LZ,CAAC"}
1
+ {"version":3,"file":"mdz_stream_parser_list.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/mdz_stream_parser_list.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAmBH,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,SAAS,EAad,MAAM,8BAA8B,CAAC;AAEtC;;;;;;;GAOG;AACH,MAAM,WAAW,mBAAmB;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,OAAO,CAAC;CACf;AAoBD;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,GAC7B,OAAO,oBAAoB,EAC3B,GAAG,MAAM,EACT,QAAQ,OAAO,KACb,mBAAmB,GAAG,SAAS,GAAG,IAsDpC,CAAC;AAEF;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACpC,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,qDAAqD;IACrD,aAAa,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAC9B,OAAO,oBAAoB,EAC3B,GAAG,MAAM,EACT,QAAQ,OAAO,KACb,oBAAoB,GAAG,SAAS,GAAG,IAuBrC,CAAC;AAgGF;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,GAAI,OAAO,oBAAoB,EAAE,QAAQ,OAAO,KAAG,SAY7E,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,iBAAiB,GAC7B,OAAO,oBAAoB,EAC3B,QAAQ,OAAO,KACb,OAAO,GAAG,YAqMZ,CAAC"}
@@ -334,8 +334,16 @@ export const process_list_line = (state, forced) => {
334
334
  }
335
335
  if (marker !== null && marker.empty) {
336
336
  // empty item: only at an indent exactly matching an open level of the
337
- // same marker type — anything else degrades below
338
- const level_idx = state.list_levels.findIndex((l) => l.indent === indent && l.ordered === marker.ordered);
337
+ // same marker type — anything else degrades below (manual loop like
338
+ // `list_continuation_target` — no per-line closure allocation)
339
+ let level_idx = -1;
340
+ for (let li = 0; li < state.list_levels.length; li++) {
341
+ const l = state.list_levels[li];
342
+ if (l.indent === indent && l.ordered === marker.ordered) {
343
+ level_idx = li;
344
+ break;
345
+ }
346
+ }
339
347
  if (level_idx !== -1) {
340
348
  close_list_run(state);
341
349
  while (state.list_levels.length - 1 > level_idx) {