@fuzdev/mdz 0.1.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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/dist/Mdz.svelte +39 -0
  4. package/dist/Mdz.svelte.d.ts +17 -0
  5. package/dist/Mdz.svelte.d.ts.map +1 -0
  6. package/dist/MdzNodeView.svelte +140 -0
  7. package/dist/MdzNodeView.svelte.d.ts +9 -0
  8. package/dist/MdzNodeView.svelte.d.ts.map +1 -0
  9. package/dist/MdzPrecompiled.svelte +33 -0
  10. package/dist/MdzPrecompiled.svelte.d.ts +17 -0
  11. package/dist/MdzPrecompiled.svelte.d.ts.map +1 -0
  12. package/dist/MdzRoot.svelte +40 -0
  13. package/dist/MdzRoot.svelte.d.ts +14 -0
  14. package/dist/MdzRoot.svelte.d.ts.map +1 -0
  15. package/dist/MdzStream.svelte +37 -0
  16. package/dist/MdzStream.svelte.d.ts +18 -0
  17. package/dist/MdzStream.svelte.d.ts.map +1 -0
  18. package/dist/MdzStreamNodeView.svelte +150 -0
  19. package/dist/MdzStreamNodeView.svelte.d.ts +9 -0
  20. package/dist/MdzStreamNodeView.svelte.d.ts.map +1 -0
  21. package/dist/context_helpers.d.ts +23 -0
  22. package/dist/context_helpers.d.ts.map +1 -0
  23. package/dist/context_helpers.js +19 -0
  24. package/dist/mdz.d.ts +152 -0
  25. package/dist/mdz.d.ts.map +1 -0
  26. package/dist/mdz.js +61 -0
  27. package/dist/mdz_contexts.d.ts +106 -0
  28. package/dist/mdz_contexts.d.ts.map +1 -0
  29. package/dist/mdz_contexts.js +42 -0
  30. package/dist/mdz_helpers.d.ts +321 -0
  31. package/dist/mdz_helpers.d.ts.map +1 -0
  32. package/dist/mdz_helpers.js +668 -0
  33. package/dist/mdz_lexer.d.ts +123 -0
  34. package/dist/mdz_lexer.d.ts.map +1 -0
  35. package/dist/mdz_lexer.js +1405 -0
  36. package/dist/mdz_opcodes.d.ts +193 -0
  37. package/dist/mdz_opcodes.d.ts.map +1 -0
  38. package/dist/mdz_opcodes.js +14 -0
  39. package/dist/mdz_opcodes_to_nodes.d.ts +26 -0
  40. package/dist/mdz_opcodes_to_nodes.d.ts.map +1 -0
  41. package/dist/mdz_opcodes_to_nodes.js +410 -0
  42. package/dist/mdz_stream_parser.d.ts +109 -0
  43. package/dist/mdz_stream_parser.d.ts.map +1 -0
  44. package/dist/mdz_stream_parser.js +699 -0
  45. package/dist/mdz_stream_parser_block.d.ts +99 -0
  46. package/dist/mdz_stream_parser_block.d.ts.map +1 -0
  47. package/dist/mdz_stream_parser_block.js +437 -0
  48. package/dist/mdz_stream_parser_inline.d.ts +98 -0
  49. package/dist/mdz_stream_parser_inline.d.ts.map +1 -0
  50. package/dist/mdz_stream_parser_inline.js +481 -0
  51. package/dist/mdz_stream_parser_link.d.ts +29 -0
  52. package/dist/mdz_stream_parser_link.d.ts.map +1 -0
  53. package/dist/mdz_stream_parser_link.js +227 -0
  54. package/dist/mdz_stream_parser_list.d.ts +106 -0
  55. package/dist/mdz_stream_parser_list.d.ts.map +1 -0
  56. package/dist/mdz_stream_parser_list.js +438 -0
  57. package/dist/mdz_stream_parser_state.d.ts +476 -0
  58. package/dist/mdz_stream_parser_state.d.ts.map +1 -0
  59. package/dist/mdz_stream_parser_state.js +651 -0
  60. package/dist/mdz_stream_parser_text.d.ts +25 -0
  61. package/dist/mdz_stream_parser_text.d.ts.map +1 -0
  62. package/dist/mdz_stream_parser_text.js +67 -0
  63. package/dist/mdz_stream_parser_url.d.ts +76 -0
  64. package/dist/mdz_stream_parser_url.d.ts.map +1 -0
  65. package/dist/mdz_stream_parser_url.js +328 -0
  66. package/dist/mdz_stream_state.svelte.d.ts +53 -0
  67. package/dist/mdz_stream_state.svelte.d.ts.map +1 -0
  68. package/dist/mdz_stream_state.svelte.js +360 -0
  69. package/dist/mdz_to_svelte.d.ts +71 -0
  70. package/dist/mdz_to_svelte.d.ts.map +1 -0
  71. package/dist/mdz_to_svelte.js +127 -0
  72. package/dist/mdz_token_parser.d.ts +20 -0
  73. package/dist/mdz_token_parser.d.ts.map +1 -0
  74. package/dist/mdz_token_parser.js +449 -0
  75. package/dist/svelte_preprocess_mdz.d.ts +80 -0
  76. package/dist/svelte_preprocess_mdz.d.ts.map +1 -0
  77. package/dist/svelte_preprocess_mdz.js +543 -0
  78. package/dist/tsdoc_mdz.d.ts +45 -0
  79. package/dist/tsdoc_mdz.d.ts.map +1 -0
  80. package/dist/tsdoc_mdz.js +88 -0
  81. package/package.json +131 -0
  82. package/src/lib/context_helpers.ts +52 -0
  83. package/src/lib/mdz.ts +190 -0
  84. package/src/lib/mdz_contexts.ts +99 -0
  85. package/src/lib/mdz_helpers.ts +727 -0
  86. package/src/lib/mdz_lexer.ts +1761 -0
  87. package/src/lib/mdz_opcodes.ts +227 -0
  88. package/src/lib/mdz_opcodes_to_nodes.ts +470 -0
  89. package/src/lib/mdz_stream_parser.ts +766 -0
  90. package/src/lib/mdz_stream_parser_block.ts +490 -0
  91. package/src/lib/mdz_stream_parser_inline.ts +527 -0
  92. package/src/lib/mdz_stream_parser_link.ts +283 -0
  93. package/src/lib/mdz_stream_parser_list.ts +525 -0
  94. package/src/lib/mdz_stream_parser_state.ts +957 -0
  95. package/src/lib/mdz_stream_parser_text.ts +95 -0
  96. package/src/lib/mdz_stream_parser_url.ts +386 -0
  97. package/src/lib/mdz_stream_state.svelte.ts +383 -0
  98. package/src/lib/mdz_to_svelte.ts +213 -0
  99. package/src/lib/mdz_token_parser.ts +544 -0
  100. package/src/lib/svelte_preprocess_mdz.ts +777 -0
  101. package/src/lib/tsdoc_mdz.ts +97 -0
@@ -0,0 +1,1761 @@
1
+ /**
2
+ * mdz lexer — tokenizes input into a flat `MdzToken[]` stream.
3
+ *
4
+ * Phase 1 of the synchronous parsing pipeline behind `mdz_parse`.
5
+ * Phase 2 (token parser) is in `mdz_token_parser.ts`.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import {
11
+ mdz_is_url,
12
+ mdz_is_safe_reference,
13
+ is_letter,
14
+ is_tag_name_char,
15
+ is_word_char,
16
+ is_valid_path_char,
17
+ mdz_trim_url_punctuation,
18
+ BACKTICK,
19
+ ASTERISK,
20
+ UNDERSCORE,
21
+ TILDE,
22
+ NEWLINE,
23
+ HYPHEN,
24
+ HASH,
25
+ SPACE,
26
+ LEFT_ANGLE,
27
+ RIGHT_ANGLE,
28
+ SLASH,
29
+ PERIOD,
30
+ LEFT_BRACKET,
31
+ LEFT_PAREN,
32
+ RIGHT_BRACKET,
33
+ A_UPPER,
34
+ Z_UPPER,
35
+ ZERO,
36
+ ONE,
37
+ NINE,
38
+ HR_HYPHEN_COUNT,
39
+ MIN_CODEBLOCK_BACKTICKS,
40
+ MAX_HEADING_LEVEL,
41
+ MAX_LIST_NUMBER_DIGITS,
42
+ match_url_prefix_case_insensitive,
43
+ is_at_absolute_path,
44
+ is_at_relative_path,
45
+ is_line_whitespace,
46
+ mdz_match_blockquote_prefix,
47
+ mdz_blockquote_line_has_content,
48
+ mdz_blockquote_strip_width,
49
+ mdz_remap_segments,
50
+ } from './mdz_helpers.js';
51
+
52
+ //
53
+ // Token types
54
+ //
55
+
56
+ export interface MdzTokenBase {
57
+ start: number;
58
+ end: number;
59
+ }
60
+
61
+ export type MdzToken =
62
+ | MdzTokenText
63
+ | MdzTokenCode
64
+ | MdzTokenCodeblock
65
+ | MdzTokenBoldOpen
66
+ | MdzTokenBoldClose
67
+ | MdzTokenItalicOpen
68
+ | MdzTokenItalicClose
69
+ | MdzTokenStrikethroughOpen
70
+ | MdzTokenStrikethroughClose
71
+ | MdzTokenLinkTextOpen
72
+ | MdzTokenLinkTextClose
73
+ | MdzTokenLinkRef
74
+ | MdzTokenAutolink
75
+ | MdzTokenHeadingStart
76
+ | MdzTokenHr
77
+ | MdzTokenTagOpen
78
+ | MdzTokenTagSelfClose
79
+ | MdzTokenTagClose
80
+ | MdzTokenHeadingEnd
81
+ | MdzTokenParagraphBreak
82
+ | MdzTokenListOpen
83
+ | MdzTokenListClose
84
+ | MdzTokenListItemOpen
85
+ | MdzTokenListItemClose
86
+ | MdzTokenBlockquoteOpen
87
+ | MdzTokenBlockquoteClose;
88
+
89
+ export interface MdzTokenText extends MdzTokenBase {
90
+ type: 'text';
91
+ content: string;
92
+ }
93
+
94
+ export interface MdzTokenCode extends MdzTokenBase {
95
+ type: 'code';
96
+ content: string;
97
+ }
98
+
99
+ export interface MdzTokenCodeblock extends MdzTokenBase {
100
+ type: 'codeblock';
101
+ lang: string | null;
102
+ content: string;
103
+ }
104
+
105
+ export interface MdzTokenBoldOpen extends MdzTokenBase {
106
+ type: 'bold_open';
107
+ }
108
+
109
+ export interface MdzTokenBoldClose extends MdzTokenBase {
110
+ type: 'bold_close';
111
+ }
112
+
113
+ export interface MdzTokenItalicOpen extends MdzTokenBase {
114
+ type: 'italic_open';
115
+ }
116
+
117
+ export interface MdzTokenItalicClose extends MdzTokenBase {
118
+ type: 'italic_close';
119
+ }
120
+
121
+ export interface MdzTokenStrikethroughOpen extends MdzTokenBase {
122
+ type: 'strikethrough_open';
123
+ }
124
+
125
+ export interface MdzTokenStrikethroughClose extends MdzTokenBase {
126
+ type: 'strikethrough_close';
127
+ }
128
+
129
+ export interface MdzTokenLinkTextOpen extends MdzTokenBase {
130
+ type: 'link_text_open';
131
+ }
132
+
133
+ export interface MdzTokenLinkTextClose extends MdzTokenBase {
134
+ type: 'link_text_close';
135
+ }
136
+
137
+ export interface MdzTokenLinkRef extends MdzTokenBase {
138
+ type: 'link_ref';
139
+ reference: string;
140
+ link_type: 'external' | 'internal';
141
+ }
142
+
143
+ export interface MdzTokenAutolink extends MdzTokenBase {
144
+ type: 'autolink';
145
+ reference: string;
146
+ link_type: 'external' | 'internal';
147
+ }
148
+
149
+ export interface MdzTokenHeadingStart extends MdzTokenBase {
150
+ type: 'heading_start';
151
+ level: 1 | 2 | 3 | 4 | 5 | 6;
152
+ }
153
+
154
+ export interface MdzTokenHr extends MdzTokenBase {
155
+ type: 'hr';
156
+ }
157
+
158
+ export interface MdzTokenTagOpen extends MdzTokenBase {
159
+ type: 'tag_open';
160
+ name: string;
161
+ is_component: boolean;
162
+ }
163
+
164
+ export interface MdzTokenTagSelfClose extends MdzTokenBase {
165
+ type: 'tag_self_close';
166
+ name: string;
167
+ is_component: boolean;
168
+ }
169
+
170
+ export interface MdzTokenTagClose extends MdzTokenBase {
171
+ type: 'tag_close';
172
+ name: string;
173
+ }
174
+
175
+ export interface MdzTokenHeadingEnd extends MdzTokenBase {
176
+ type: 'heading_end';
177
+ }
178
+
179
+ export interface MdzTokenParagraphBreak extends MdzTokenBase {
180
+ type: 'paragraph_break';
181
+ }
182
+
183
+ export interface MdzTokenListOpen extends MdzTokenBase {
184
+ type: 'list_open';
185
+ ordered: boolean;
186
+ }
187
+
188
+ export interface MdzTokenListClose extends MdzTokenBase {
189
+ type: 'list_close';
190
+ }
191
+
192
+ export interface MdzTokenListItemOpen extends MdzTokenBase {
193
+ type: 'list_item_open';
194
+ /** Authored number (ordered lists only). */
195
+ number?: number;
196
+ }
197
+
198
+ export interface MdzTokenListItemClose extends MdzTokenBase {
199
+ type: 'list_item_close';
200
+ }
201
+
202
+ export interface MdzTokenBlockquoteOpen extends MdzTokenBase {
203
+ type: 'blockquote_open';
204
+ }
205
+
206
+ export interface MdzTokenBlockquoteClose extends MdzTokenBase {
207
+ type: 'blockquote_close';
208
+ }
209
+
210
+ /**
211
+ * A matched list marker: `- ` (unordered) or `N. ` (ordered, 1-9 digits).
212
+ * `empty` markers have nothing but whitespace after the marker — valid only
213
+ * as mid-list empty items, never starting a list.
214
+ */
215
+ interface MdzListMarker {
216
+ ordered: boolean;
217
+ /** Parsed integer for ordered markers (`007.` → 7); `0` for unordered. */
218
+ number: number;
219
+ /** Source position of the marker character. */
220
+ start: number;
221
+ /** Index just past the marker chars (`-` or `N.`), before the space. */
222
+ marker_end: number;
223
+ /** Index just past the marker space — the inline run's content. */
224
+ content_start: number;
225
+ empty: boolean;
226
+ }
227
+
228
+ /** An open list level during `#tokenize_list` — the column of its markers. */
229
+ interface MdzListLevel {
230
+ indent: number;
231
+ ordered: boolean;
232
+ }
233
+
234
+ //
235
+ // Lexer
236
+ //
237
+
238
+ /**
239
+ * Tokenizes mdz input into a flat `MdzToken` stream for `MdzTokenParser`.
240
+ *
241
+ * The lexer owns all structural decisions — block boundaries, list level
242
+ * stacks, blockquote prefix stripping (sub-lexing quote content as a
243
+ * mini-document with positions remapped to source offsets), and inline
244
+ * delimiter pairing bounded by `#max_search_index` — so the token parser
245
+ * only assembles the tree.
246
+ */
247
+ export class MdzLexer {
248
+ #text: string;
249
+ #index: number = 0;
250
+ #tokens: Array<MdzToken> = [];
251
+ #max_search_index: number = Number.MAX_SAFE_INTEGER;
252
+ #search_memo: Map<string, {from: number; result: number}> = new Map();
253
+ #break_memo: {from: number; result: number} | null = null;
254
+ /**
255
+ * Whether `#tokenize_text` is inside a list-item inline run: single
256
+ * newlines are soft breaks whose following structural indent strips from
257
+ * content, and paragraph-break/block-lookahead checks are skipped (the run
258
+ * scanner pre-validated every line in the run as continuation).
259
+ */
260
+ #list_run_strip: boolean = false;
261
+
262
+ constructor(text: string) {
263
+ this.#text = text;
264
+ }
265
+
266
+ /**
267
+ * Memoized `String#indexOf` over the immutable text.
268
+ *
269
+ * Delimiter and closing-tag searches scan forward from the current lex
270
+ * position, and a needle that never occurs again would otherwise rescan to
271
+ * the end of the text on every attempt — making inputs dense in unclosed
272
+ * candidates quadratic (e.g. `Vec<String>`-style generics searching for
273
+ * `</String>` at every occurrence). Caching the last result per needle
274
+ * makes each missing needle scan the remaining text at most once.
275
+ */
276
+ #index_of(needle: string, from: number): number {
277
+ const memo = this.#search_memo.get(needle);
278
+ if (memo !== undefined && memo.from <= from && (memo.result === -1 || memo.result >= from)) {
279
+ return memo.result;
280
+ }
281
+ const result = this.#text.indexOf(needle, from);
282
+ if (memo === undefined) {
283
+ this.#search_memo.set(needle, {from, result});
284
+ } else {
285
+ memo.from = from;
286
+ memo.result = result;
287
+ }
288
+ return result;
289
+ }
290
+
291
+ tokenize(): Array<MdzToken> {
292
+ // Skip leading blank lines
293
+ this.#skip_blank_lines();
294
+
295
+ while (this.#index < this.#text.length) {
296
+ // Check for block elements at column 0
297
+ if (this.#is_at_column_0()) {
298
+ if (this.#tokenize_heading()) continue;
299
+ if (this.#tokenize_hr()) continue;
300
+ if (this.#tokenize_codeblock()) continue;
301
+ if (this.#tokenize_list()) continue;
302
+ if (this.#tokenize_blockquote()) continue;
303
+ }
304
+
305
+ // Check for paragraph break
306
+ if (this.#is_at_paragraph_break()) {
307
+ const start = this.#index;
308
+ this.#skip_blank_lines();
309
+ this.#tokens.push({type: 'paragraph_break', start, end: this.#index});
310
+ continue;
311
+ }
312
+
313
+ // Inline tokenization
314
+ this.#tokenize_inline();
315
+ }
316
+
317
+ return this.#tokens;
318
+ }
319
+
320
+ #is_at_column_0(): boolean {
321
+ return this.#index === 0 || this.#text.charCodeAt(this.#index - 1) === NEWLINE;
322
+ }
323
+
324
+ /**
325
+ * Whether a paragraph break starts at the current position: a newline
326
+ * followed (across optional line whitespace) by another newline — i.e. the
327
+ * next line is blank. Linear in total despite the inner scan: the scan only
328
+ * runs at newline positions and covers each whitespace run at most once
329
+ * per newline preceding it.
330
+ */
331
+ #is_at_paragraph_break(): boolean {
332
+ if (this.#text.charCodeAt(this.#index) !== NEWLINE) return false;
333
+ let i = this.#index + 1;
334
+ while (i < this.#text.length && is_line_whitespace(this.#text.charCodeAt(i))) {
335
+ i++;
336
+ }
337
+ return i < this.#text.length && this.#text.charCodeAt(i) === NEWLINE;
338
+ }
339
+
340
+ #match(str: string): boolean {
341
+ return this.#text.startsWith(str, this.#index);
342
+ }
343
+
344
+ /**
345
+ * Skip newlines and whitespace-only lines (including a trailing
346
+ * whitespace-only line at EOF). Stops at the start of any line with
347
+ * content — leading whitespace on a content line stays in the text.
348
+ */
349
+ #skip_blank_lines(): void {
350
+ while (this.#index < this.#text.length) {
351
+ const char_code = this.#text.charCodeAt(this.#index);
352
+ if (char_code === NEWLINE) {
353
+ this.#index++;
354
+ continue;
355
+ }
356
+ if (!is_line_whitespace(char_code)) return;
357
+ let i = this.#index;
358
+ while (i < this.#text.length && is_line_whitespace(this.#text.charCodeAt(i))) {
359
+ i++;
360
+ }
361
+ if (i < this.#text.length && this.#text.charCodeAt(i) !== NEWLINE) return; // line has content
362
+ this.#index = i; // blank to end of line (or EOF); newline consumed next iteration
363
+ }
364
+ }
365
+
366
+ // -- Block tokenizers --
367
+
368
+ #tokenize_heading(): boolean {
369
+ let i = this.#index;
370
+
371
+ // Count hashes (must be 1-6)
372
+ let hash_count = 0;
373
+ while (
374
+ i < this.#text.length &&
375
+ this.#text.charCodeAt(i) === HASH &&
376
+ hash_count <= MAX_HEADING_LEVEL
377
+ ) {
378
+ hash_count++;
379
+ i++;
380
+ }
381
+
382
+ if (hash_count === 0 || hash_count > MAX_HEADING_LEVEL) return false;
383
+
384
+ // Must have space after hashes
385
+ if (i >= this.#text.length || this.#text.charCodeAt(i) !== SPACE) return false;
386
+ i++; // consume the space
387
+
388
+ // Must have at least one non-whitespace character after the space
389
+ let has_content = false;
390
+ for (let j = i; j < this.#text.length; j++) {
391
+ const char_code = this.#text.charCodeAt(j);
392
+ if (char_code === NEWLINE) break;
393
+ if (!is_line_whitespace(char_code)) {
394
+ has_content = true;
395
+ break;
396
+ }
397
+ }
398
+
399
+ if (!has_content) return false;
400
+
401
+ const start = this.#index;
402
+ // Advance past "## " (hashes + space)
403
+ this.#index = start + hash_count + 1;
404
+
405
+ this.#tokens.push({
406
+ type: 'heading_start',
407
+ level: hash_count as 1 | 2 | 3 | 4 | 5 | 6,
408
+ start,
409
+ end: this.#index, // end of "## " prefix
410
+ });
411
+
412
+ // Find end-of-line to bound nested tokenizers (prevents tag scanner from scanning past heading)
413
+ let eol = this.#text.indexOf('\n', this.#index);
414
+ if (eol === -1) eol = this.#text.length;
415
+
416
+ const saved_max = this.#max_search_index;
417
+ this.#max_search_index = eol;
418
+
419
+ // Tokenize inline content until newline or EOF
420
+ // tokenize_text may consume a newline as part of block-element lookahead,
421
+ // so we check emitted text tokens for embedded newlines and trim.
422
+ while (this.#index < eol) {
423
+ const token_count_before = this.#tokens.length;
424
+ this.#tokenize_inline();
425
+
426
+ // Check if the last emitted token is text containing a newline
427
+ if (this.#tokens.length > token_count_before) {
428
+ const last_token = this.#tokens[this.#tokens.length - 1]!;
429
+ if (last_token.type === 'text') {
430
+ const newline_idx = last_token.content.indexOf('\n');
431
+ if (newline_idx !== -1) {
432
+ const trimmed = last_token.content.slice(0, newline_idx);
433
+ if (trimmed) {
434
+ last_token.content = trimmed;
435
+ last_token.end = last_token.start + trimmed.length;
436
+ } else {
437
+ this.#tokens.pop();
438
+ }
439
+ this.#index = last_token.start + newline_idx;
440
+ break;
441
+ }
442
+ }
443
+ }
444
+ }
445
+
446
+ this.#max_search_index = saved_max;
447
+
448
+ // Emit heading_end marker so the token parser knows where heading content stops
449
+ this.#tokens.push({type: 'heading_end', start: this.#index, end: this.#index});
450
+
451
+ // Skip newlines after heading
452
+ this.#skip_blank_lines();
453
+
454
+ return true;
455
+ }
456
+
457
+ #tokenize_hr(): boolean {
458
+ let i = this.#index;
459
+
460
+ // Must have exactly three hyphens
461
+ if (
462
+ i + HR_HYPHEN_COUNT > this.#text.length ||
463
+ this.#text.charCodeAt(i) !== HYPHEN ||
464
+ this.#text.charCodeAt(i + 1) !== HYPHEN ||
465
+ this.#text.charCodeAt(i + 2) !== HYPHEN
466
+ ) {
467
+ return false;
468
+ }
469
+ i += HR_HYPHEN_COUNT;
470
+
471
+ // After the three hyphens, only whitespace and newline (or EOF) allowed
472
+ while (i < this.#text.length) {
473
+ const char_code = this.#text.charCodeAt(i);
474
+ if (char_code === NEWLINE) break;
475
+ if (!is_line_whitespace(char_code)) return false;
476
+ i++;
477
+ }
478
+
479
+ const start = this.#index;
480
+ this.#index = i; // consume up to but not including newline
481
+
482
+ this.#tokens.push({type: 'hr', start, end: this.#index});
483
+
484
+ // Skip newlines after HR
485
+ this.#skip_blank_lines();
486
+
487
+ return true;
488
+ }
489
+
490
+ #tokenize_codeblock(): boolean {
491
+ // a top-level fence is the indent-0 case of the general fence matcher
492
+ const fence = this.#match_fence(this.#index, 0);
493
+ if (fence === null) return false;
494
+
495
+ this.#tokens.push(fence.token);
496
+ this.#index = fence.end;
497
+
498
+ // Skip newlines after codeblock
499
+ this.#skip_blank_lines();
500
+
501
+ return true;
502
+ }
503
+
504
+ // -- Blockquote tokenizers --
505
+
506
+ /**
507
+ * Match a quote prefix in the complete sync text — never `'pending'`.
508
+ * See `mdz_match_blockquote_prefix`.
509
+ */
510
+ #match_blockquote_prefix(i: number): {depth: number; content_start: number} | null {
511
+ const prefix = mdz_match_blockquote_prefix(this.#text, i, true);
512
+ return prefix === 'pending' ? null : prefix;
513
+ }
514
+
515
+ /**
516
+ * Whether the line at `i` opens a quote: a valid prefix with non-whitespace
517
+ * content after the full prefix on the same line (contentless prefix lines
518
+ * never start a quote — no empty start, mirroring lists).
519
+ */
520
+ #is_blockquote_opener(i: number): boolean {
521
+ const prefix = this.#match_blockquote_prefix(i);
522
+ return (
523
+ prefix !== null &&
524
+ mdz_blockquote_line_has_content(this.#text, prefix.content_start, true) === true
525
+ );
526
+ }
527
+
528
+ /**
529
+ * Tokenize a blockquote whose opener line is at column 0. Returns false
530
+ * with no side effects when the line doesn't open one. Consumes trailing
531
+ * blank lines after the quote, mirroring the other block tokenizers.
532
+ */
533
+ #tokenize_blockquote(): boolean {
534
+ if (this.#text.charCodeAt(this.#index) !== RIGHT_ANGLE) return false;
535
+ if (!this.#is_blockquote_opener(this.#index)) return false;
536
+ this.#tokenize_blockquote_at(this.#index, -1);
537
+ this.#skip_blank_lines();
538
+ return true;
539
+ }
540
+
541
+ /**
542
+ * Tokenize a blockquote starting at the validated opener position `first`.
543
+ * A quote's content is a mini-document behind the per-line prefix: the
544
+ * extent scan collects consecutive prefix lines (a blank or non-prefix
545
+ * line ends the quote — no lazy continuation), one prefix level strips
546
+ * from each line (deeper `>`s stay in the content for the nested document
547
+ * to parse), and the stripped content sub-lexes through a fresh `MdzLexer`
548
+ * with token positions remapped to source offsets. Nested quotes, lists,
549
+ * fences, and headings inside all follow from the recursion.
550
+ *
551
+ * `item_indent` is -1 for top-level quotes (continuation prefixes sit at
552
+ * column 0 exactly) or the attach item's marker indent for in-item quotes
553
+ * (continuation lines carry any indent deeper than it before their
554
+ * prefix). Leaves `#index` at the last quote line's end, before its
555
+ * newline (or at EOF).
556
+ */
557
+ #tokenize_blockquote_at(first: number, item_indent: number): void {
558
+ // the inner document is the stripped source region including each line's
559
+ // terminating newline — only a source-EOF-cut final line lacks one, so a
560
+ // quote's content parses exactly like a standalone document (a complete
561
+ // fence opener line on the last quote line opens an empty fence, not text)
562
+ const parts: Array<string> = [];
563
+ const segments: Array<{inner_start: number; source_start: number}> = [];
564
+ let inner_len = 0;
565
+ let line_first = first;
566
+ let last_line_end = first;
567
+ while (true) {
568
+ const strip = line_first + mdz_blockquote_strip_width(this.#text, line_first);
569
+ let nl = this.#text.indexOf('\n', line_first);
570
+ if (nl === -1) nl = this.#text.length;
571
+ segments.push({inner_start: inner_len, source_start: strip});
572
+ const content = this.#text.slice(Math.min(strip, nl), nl < this.#text.length ? nl + 1 : nl);
573
+ parts.push(content);
574
+ inner_len += content.length;
575
+ last_line_end = nl;
576
+ if (nl >= this.#text.length) break;
577
+ // the next line continues the quote iff it carries a valid prefix at
578
+ // the required position: column 0 exactly for top-level quotes, any
579
+ // indent deeper than the item's marker for in-item quotes
580
+ const line_start = nl + 1;
581
+ let next_first = line_start;
582
+ if (item_indent >= 0) {
583
+ while (
584
+ next_first < this.#text.length &&
585
+ is_line_whitespace(this.#text.charCodeAt(next_first))
586
+ ) {
587
+ next_first++;
588
+ }
589
+ if (next_first - line_start <= item_indent) break;
590
+ }
591
+ if (this.#text.charCodeAt(next_first) !== RIGHT_ANGLE) break;
592
+ if (this.#match_blockquote_prefix(next_first) === null) break;
593
+ line_first = next_first;
594
+ }
595
+
596
+ this.#tokens.push({type: 'blockquote_open', start: first, end: first});
597
+ const inner_tokens = new MdzLexer(parts.join('')).tokenize();
598
+ for (const token of inner_tokens) {
599
+ token.start = mdz_remap_segments(segments, token.start);
600
+ token.end = mdz_remap_segments(segments, token.end);
601
+ this.#tokens.push(token);
602
+ }
603
+ this.#tokens.push({type: 'blockquote_close', start: last_line_end, end: last_line_end});
604
+ this.#index = last_line_end;
605
+ }
606
+
607
+ // -- List tokenizers --
608
+
609
+ /**
610
+ * Tokenize an entire list starting at a column-0 marker line, emitting
611
+ * `list_open`/`list_item_open`/`list_item_close`/`list_close` structure
612
+ * tokens (plus `paragraph_break` and `codeblock` for in-item block
613
+ * children). Returns false with no side effects when the current line
614
+ * doesn't open a list: not a marker, an empty marker (empty items never
615
+ * start a list), or a non-`1.` ordered marker interrupting a paragraph.
616
+ */
617
+ #tokenize_list(): boolean {
618
+ const first_marker = this.#match_list_marker(this.#index);
619
+ if (first_marker === null || first_marker.empty) return false;
620
+ // ordered interrupt guard: only `1.` opens an ordered list against a
621
+ // current paragraph, so hard-wrapped `2024. The…` lines stay prose
622
+ if (first_marker.ordered && first_marker.number !== 1 && this.#in_paragraph()) return false;
623
+
624
+ const levels: Array<MdzListLevel> = [];
625
+ this.#list_open(levels, 0, first_marker);
626
+ this.#list_run(levels, first_marker.content_start);
627
+
628
+ // line loop — each iteration starts with #index at the newline (or EOF)
629
+ // that terminated the previous run, block child, or empty-marker line
630
+ while (this.#index < this.#text.length) {
631
+ this.#index++; // consume the newline
632
+
633
+ // blank lines are contained, not terminal: scan to the next content
634
+ // line and let its shape decide (sibling/nest/paragraph-child/close)
635
+ let line_start = this.#index;
636
+ let first = line_start;
637
+ let at_eof = false;
638
+ while (true) {
639
+ let i = first;
640
+ while (i < this.#text.length && is_line_whitespace(this.#text.charCodeAt(i))) {
641
+ i++;
642
+ }
643
+ if (i >= this.#text.length) {
644
+ // blank to EOF — consume it; the list closes at EOF
645
+ this.#index = i;
646
+ at_eof = true;
647
+ break;
648
+ }
649
+ if (this.#text.charCodeAt(i) === NEWLINE) {
650
+ line_start = i + 1; // blank line — keep scanning
651
+ first = line_start;
652
+ continue;
653
+ }
654
+ first = i; // content line found
655
+ break;
656
+ }
657
+ if (at_eof) break;
658
+
659
+ const indent = first - line_start;
660
+ const deepest = levels[levels.length - 1]!;
661
+ const marker = this.#match_list_marker(first);
662
+
663
+ if (marker !== null && !marker.empty) {
664
+ if (indent > deepest.indent) {
665
+ // indenting past the deepest level nests (any ordered number here:
666
+ // the `2024.`-guard lives in the run scanner, and reaching this
667
+ // line means a blank or block boundary intervened)
668
+ this.#list_open(levels, indent, marker);
669
+ } else {
670
+ // a dedent snaps to the nearest surviving level
671
+ while (levels.length > 1 && levels[levels.length - 1]!.indent > indent) {
672
+ this.#list_close(levels);
673
+ }
674
+ if (levels[levels.length - 1]!.ordered === marker.ordered) {
675
+ // sibling item
676
+ this.#tokens.push({type: 'list_item_close', start: first, end: first});
677
+ this.#list_item_open(marker);
678
+ } else {
679
+ // marker-type switch: close the list, open a new one at this indent
680
+ this.#list_close(levels);
681
+ this.#list_open(levels, indent, marker);
682
+ }
683
+ }
684
+ this.#list_run(levels, marker.content_start);
685
+ continue;
686
+ }
687
+
688
+ if (marker !== null && marker.empty) {
689
+ // empty item: only at an indent exactly matching an open level of
690
+ // the same marker type — anything else degrades below
691
+ const level_idx = levels.findIndex(
692
+ (l) => l.indent === indent && l.ordered === marker.ordered,
693
+ );
694
+ if (level_idx !== -1) {
695
+ while (levels.length - 1 > level_idx) {
696
+ this.#list_close(levels);
697
+ }
698
+ this.#tokens.push({type: 'list_item_close', start: first, end: first});
699
+ this.#list_item_open(marker);
700
+ // consume the marker and any trailing whitespace, leaving #index
701
+ // at the line's newline (or EOF)
702
+ let j = marker.marker_end;
703
+ while (j < this.#text.length && is_line_whitespace(this.#text.charCodeAt(j))) {
704
+ j++;
705
+ }
706
+ this.#index = j;
707
+ continue;
708
+ }
709
+ }
710
+
711
+ // a deeper line that classifies as a valid fence open becomes a code
712
+ // block child of the item it would otherwise continue
713
+ if (indent > 0 && this.#text.charCodeAt(first) === BACKTICK) {
714
+ const target_idx = this.#list_continuation_target(levels, indent);
715
+ if (target_idx !== -1) {
716
+ const fence = this.#match_fence(first, indent);
717
+ if (fence !== null) {
718
+ while (levels.length - 1 > target_idx) {
719
+ this.#list_close(levels);
720
+ }
721
+ this.#tokens.push(fence.token);
722
+ this.#index = fence.end;
723
+ continue;
724
+ }
725
+ }
726
+ }
727
+
728
+ // a deeper quote-opener line becomes a blockquote child of the item
729
+ // it would otherwise continue (the fence precedent — interrupts a
730
+ // current run without a blank line)
731
+ if (indent > 0 && this.#text.charCodeAt(first) === RIGHT_ANGLE) {
732
+ const target_idx = this.#list_continuation_target(levels, indent);
733
+ if (target_idx !== -1 && this.#is_blockquote_opener(first)) {
734
+ while (levels.length - 1 > target_idx) {
735
+ this.#list_close(levels);
736
+ }
737
+ this.#tokenize_blockquote_at(first, levels[levels.length - 1]!.indent);
738
+ continue;
739
+ }
740
+ }
741
+
742
+ // any other line deeper than an open item's marker opens a new
743
+ // `Paragraph` block child of that item (the item's inline run is never
744
+ // current here — a blank, block child, or dedent intervened)
745
+ if (indent > 0) {
746
+ const target_idx = this.#list_continuation_target(levels, indent);
747
+ if (target_idx !== -1) {
748
+ while (levels.length - 1 > target_idx) {
749
+ this.#list_close(levels);
750
+ }
751
+ this.#tokens.push({type: 'paragraph_break', start: line_start, end: line_start});
752
+ this.#list_run(levels, first);
753
+ continue;
754
+ }
755
+ }
756
+
757
+ // nothing list-shaped — the list ends; the main loop re-dispatches
758
+ // this line (paragraph, heading, HR, fence)
759
+ this.#index = line_start;
760
+ break;
761
+ }
762
+
763
+ while (levels.length > 0) {
764
+ this.#list_close(levels);
765
+ }
766
+ return true;
767
+ }
768
+
769
+ /**
770
+ * Match a list marker at `i`: `-` or 1-9 digits + `.`, followed by exactly
771
+ * one space and content — or by nothing but whitespace to end of line,
772
+ * which is an `empty` marker. Returns null for anything else (`-x`, `--`,
773
+ * a tab after the marker, a 10+ digit number).
774
+ */
775
+ #match_list_marker(i: number): MdzListMarker | null {
776
+ const c = this.#text.charCodeAt(i);
777
+ if (c === HYPHEN) {
778
+ const after = i + 1;
779
+ if (this.#rest_is_blank(after)) {
780
+ return {
781
+ ordered: false,
782
+ number: 0,
783
+ start: i,
784
+ marker_end: after,
785
+ content_start: after,
786
+ empty: true,
787
+ };
788
+ }
789
+ if (this.#text.charCodeAt(after) !== SPACE) return null;
790
+ const content_start = after + 1;
791
+ const empty = this.#rest_is_blank(content_start);
792
+ return {ordered: false, number: 0, start: i, marker_end: after, content_start, empty};
793
+ }
794
+ if (c >= ZERO && c <= NINE) {
795
+ let j = i + 1;
796
+ while (
797
+ j < this.#text.length &&
798
+ this.#text.charCodeAt(j) >= ZERO &&
799
+ this.#text.charCodeAt(j) <= NINE
800
+ ) {
801
+ j++;
802
+ }
803
+ if (j - i > MAX_LIST_NUMBER_DIGITS) return null;
804
+ if (j >= this.#text.length || this.#text.charCodeAt(j) !== PERIOD) return null;
805
+ const number = Number(this.#text.slice(i, j));
806
+ const after = j + 1;
807
+ if (this.#rest_is_blank(after)) {
808
+ return {
809
+ ordered: true,
810
+ number,
811
+ start: i,
812
+ marker_end: after,
813
+ content_start: after,
814
+ empty: true,
815
+ };
816
+ }
817
+ if (this.#text.charCodeAt(after) !== SPACE) return null;
818
+ const content_start = after + 1;
819
+ const empty = this.#rest_is_blank(content_start);
820
+ return {ordered: true, number, start: i, marker_end: after, content_start, empty};
821
+ }
822
+ return null;
823
+ }
824
+
825
+ /** Whether only line whitespace remains between `from` and the line's end (or EOF). */
826
+ #rest_is_blank(from: number): boolean {
827
+ let i = from;
828
+ while (i < this.#text.length && is_line_whitespace(this.#text.charCodeAt(i))) {
829
+ i++;
830
+ }
831
+ return i >= this.#text.length || this.#text.charCodeAt(i) === NEWLINE;
832
+ }
833
+
834
+ /**
835
+ * Whether a paragraph's inline run is current — used by the ordered
836
+ * interrupt guard. True when the last emitted token isn't a block boundary.
837
+ */
838
+ #in_paragraph(): boolean {
839
+ if (this.#tokens.length === 0) return false;
840
+ const type = this.#tokens[this.#tokens.length - 1]!.type;
841
+ return (
842
+ type !== 'paragraph_break' &&
843
+ type !== 'hr' &&
844
+ type !== 'codeblock' &&
845
+ type !== 'heading_end' &&
846
+ type !== 'list_close' &&
847
+ type !== 'blockquote_close'
848
+ );
849
+ }
850
+
851
+ /** Open a new list level (list + first item) at `indent`. */
852
+ #list_open(levels: Array<MdzListLevel>, indent: number, marker: MdzListMarker): void {
853
+ this.#tokens.push({
854
+ type: 'list_open',
855
+ ordered: marker.ordered,
856
+ start: marker.start,
857
+ end: marker.start,
858
+ });
859
+ this.#list_item_open(marker);
860
+ levels.push({indent, ordered: marker.ordered});
861
+ }
862
+
863
+ /** Emit a `list_item_open` token for `marker`. */
864
+ #list_item_open(marker: MdzListMarker): void {
865
+ const end = marker.empty ? marker.marker_end : marker.content_start;
866
+ if (marker.ordered) {
867
+ this.#tokens.push({type: 'list_item_open', number: marker.number, start: marker.start, end});
868
+ } else {
869
+ this.#tokens.push({type: 'list_item_open', start: marker.start, end});
870
+ }
871
+ }
872
+
873
+ /** Close the deepest open level: its current item, then its list. */
874
+ #list_close(levels: Array<MdzListLevel>): void {
875
+ this.#tokens.push({type: 'list_item_close', start: this.#index, end: this.#index});
876
+ this.#tokens.push({type: 'list_close', start: this.#index, end: this.#index});
877
+ levels.pop();
878
+ }
879
+
880
+ /**
881
+ * The level index of the deepest open item a line at `indent` can attach
882
+ * to — the deepest level with marker indent strictly less than `indent` —
883
+ * or -1. Levels above it get popped by the caller.
884
+ */
885
+ #list_continuation_target(levels: Array<MdzListLevel>, indent: number): number {
886
+ for (let i = levels.length - 1; i >= 0; i--) {
887
+ if (levels[i]!.indent < indent) return i;
888
+ }
889
+ return -1;
890
+ }
891
+
892
+ /**
893
+ * Tokenize one inline run (a marker line's content, or a paragraph block
894
+ * child) including its continuation lines. The run is a
895
+ * paragraph-equivalent region: `#max_search_index` bounds delimiter
896
+ * searches to the run's end, so inline pairs never cross item boundaries,
897
+ * and `#list_run_strip` makes single newlines soft breaks with the
898
+ * continuation indent stripped from content. Leaves `#index` at the run's
899
+ * terminating newline (or EOF).
900
+ */
901
+ #list_run(levels: Array<MdzListLevel>, content_start: number): void {
902
+ // extra whitespace after the marker space is content-leading slop, trimmed
903
+ let i = content_start;
904
+ while (i < this.#text.length && is_line_whitespace(this.#text.charCodeAt(i))) {
905
+ i++;
906
+ }
907
+ this.#index = i;
908
+ const run_end = this.#list_run_end(i, levels[levels.length - 1]!.indent);
909
+ const saved_max = this.#max_search_index;
910
+ this.#max_search_index = run_end;
911
+ this.#list_run_strip = true;
912
+ while (this.#index < run_end) {
913
+ this.#tokenize_inline();
914
+ }
915
+ this.#list_run_strip = false;
916
+ this.#max_search_index = saved_max;
917
+ }
918
+
919
+ /**
920
+ * Scan forward from `from` for the end of the current inline run: the
921
+ * newline (or EOF) before the first line that isn't a continuation. A line
922
+ * continues the run when it's non-blank, indented deeper than the deepest
923
+ * open marker, and isn't a nesting marker or a fence opener line. A deeper
924
+ * non-`1.` ordered marker against the current run is continuation text
925
+ * (the hard-wrap `2024.` guard); a deeper empty marker is plain text.
926
+ */
927
+ #list_run_end(from: number, deepest_indent: number): number {
928
+ let i = from;
929
+ while (true) {
930
+ const nl = this.#text.indexOf('\n', i);
931
+ if (nl === -1) return this.#text.length;
932
+ const line_start = nl + 1;
933
+ let j = line_start;
934
+ while (j < this.#text.length && is_line_whitespace(this.#text.charCodeAt(j))) {
935
+ j++;
936
+ }
937
+ if (j >= this.#text.length || this.#text.charCodeAt(j) === NEWLINE) return nl; // blank
938
+ const indent = j - line_start;
939
+ if (indent <= deepest_indent) return nl; // sibling, dedent, or list end
940
+ const marker = this.#match_list_marker(j);
941
+ if (marker !== null && !marker.empty && (!marker.ordered || marker.number === 1)) {
942
+ return nl; // a nested list opens
943
+ }
944
+ if (this.#text.charCodeAt(j) === BACKTICK && this.#is_fence_opener(j)) {
945
+ return nl; // a fence opener line interrupts the run
946
+ }
947
+ if (this.#text.charCodeAt(j) === RIGHT_ANGLE && this.#is_blockquote_opener(j)) {
948
+ return nl; // a quote opener line interrupts the run
949
+ }
950
+ i = line_start; // continuation — keep scanning
951
+ }
952
+ }
953
+
954
+ /**
955
+ * Match a fenced code block whose opener is at `first` with the given
956
+ * `fence_indent` — 0 for top-level fences, the line's indent for in-item
957
+ * fences. A complete opener line always opens the fence, and raw mode is
958
+ * absolute: every following line is content until a closing fence (>= the
959
+ * opener's backticks, only trailing whitespace, at any indent <= the
960
+ * opener's) or EOF — a dedented or column-0 content line never
961
+ * force-closes, and an unclosed fence consumes to EOF (one trailing
962
+ * newline trimmed, matching closed fences' content shape). Content lines
963
+ * strip `min(fence_indent, line_indent)` leading whitespace. Returns null
964
+ * only when the opener line is malformed — including an opener cut off at
965
+ * EOF without its newline; `end` is the closer line's end before its
966
+ * newline, or EOF when unclosed.
967
+ */
968
+ #match_fence(
969
+ first: number,
970
+ fence_indent: number,
971
+ ): {token: MdzTokenCodeblock; end: number} | null {
972
+ let i = first;
973
+ let backtick_count = 0;
974
+ while (i < this.#text.length && this.#text.charCodeAt(i) === BACKTICK) {
975
+ backtick_count++;
976
+ i++;
977
+ }
978
+ if (backtick_count < MIN_CODEBLOCK_BACKTICKS) return null;
979
+
980
+ const lang_start = i;
981
+ while (i < this.#text.length) {
982
+ const c = this.#text.charCodeAt(i);
983
+ if (c === NEWLINE || is_line_whitespace(c)) break;
984
+ i++;
985
+ }
986
+ const lang = i > lang_start ? this.#text.slice(lang_start, i) : null;
987
+ while (i < this.#text.length && is_line_whitespace(this.#text.charCodeAt(i))) {
988
+ i++;
989
+ }
990
+ if (i >= this.#text.length || this.#text.charCodeAt(i) !== NEWLINE) return null;
991
+ i++; // past the opener's newline
992
+
993
+ const content_start = i;
994
+ const content_lines: Array<string> = [];
995
+ while (i <= this.#text.length) {
996
+ const line_start = i;
997
+ let j = i;
998
+ while (j < this.#text.length && is_line_whitespace(this.#text.charCodeAt(j))) {
999
+ j++;
1000
+ }
1001
+ const line_indent = j - line_start;
1002
+ // closer probe: indent <= the opener's, a run of >= the opener's
1003
+ // backticks, then only whitespace to end of line
1004
+ if (
1005
+ line_indent <= fence_indent &&
1006
+ j < this.#text.length &&
1007
+ this.#text.charCodeAt(j) === BACKTICK
1008
+ ) {
1009
+ let k = j;
1010
+ let count = 0;
1011
+ while (k < this.#text.length && this.#text.charCodeAt(k) === BACKTICK) {
1012
+ count++;
1013
+ k++;
1014
+ }
1015
+ if (count >= backtick_count) {
1016
+ let m = k;
1017
+ while (m < this.#text.length && is_line_whitespace(this.#text.charCodeAt(m))) {
1018
+ m++;
1019
+ }
1020
+ if (m >= this.#text.length || this.#text.charCodeAt(m) === NEWLINE) {
1021
+ // at indent 0 nothing ever strips, so content is one slice
1022
+ // (dropping the newline before the closer line)
1023
+ const content =
1024
+ fence_indent === 0
1025
+ ? this.#text.slice(content_start, Math.max(content_start, line_start - 1))
1026
+ : content_lines.join('\n');
1027
+ return {
1028
+ token: {type: 'codeblock', lang, content, start: first, end: m},
1029
+ end: m,
1030
+ };
1031
+ }
1032
+ }
1033
+ }
1034
+ // content line — strip the structural indent, never non-whitespace
1035
+ const nl = this.#text.indexOf('\n', line_start);
1036
+ if (fence_indent > 0) {
1037
+ const line_end = nl === -1 ? this.#text.length : nl;
1038
+ content_lines.push(
1039
+ this.#text.slice(line_start + Math.min(fence_indent, line_indent), line_end),
1040
+ );
1041
+ }
1042
+ if (nl === -1) break;
1043
+ i = nl + 1;
1044
+ }
1045
+ // unclosed — raw mode is absolute, so the fence consumes to EOF; one
1046
+ // trailing newline trims, matching closed fences' content shape
1047
+ let content = fence_indent === 0 ? this.#text.slice(content_start) : content_lines.join('\n');
1048
+ if (content.endsWith('\n')) content = content.slice(0, -1);
1049
+ return {
1050
+ token: {type: 'codeblock', lang, content, start: first, end: this.#text.length},
1051
+ end: this.#text.length,
1052
+ };
1053
+ }
1054
+
1055
+ /**
1056
+ * Whether a complete fence opener line starts at `i`: a run of >=3
1057
+ * backticks, an optional language word, then only whitespace up to a
1058
+ * terminating newline — an opener cut off at EOF stays text. With
1059
+ * raw-mode-absolute fences this alone decides fence-ness (`#match_fence`
1060
+ * never fails on a complete opener), so run-end scans use this cheap
1061
+ * probe instead of consuming the fence.
1062
+ */
1063
+ #is_fence_opener(i: number): boolean {
1064
+ let j = i;
1065
+ let count = 0;
1066
+ while (j < this.#text.length && this.#text.charCodeAt(j) === BACKTICK) {
1067
+ count++;
1068
+ j++;
1069
+ }
1070
+ if (count < MIN_CODEBLOCK_BACKTICKS) return false;
1071
+ // optional language word: non-whitespace run ends at whitespace or the newline
1072
+ while (j < this.#text.length) {
1073
+ const c = this.#text.charCodeAt(j);
1074
+ if (c === NEWLINE || is_line_whitespace(c)) break;
1075
+ j++;
1076
+ }
1077
+ while (j < this.#text.length && is_line_whitespace(this.#text.charCodeAt(j))) {
1078
+ j++;
1079
+ }
1080
+ return j < this.#text.length && this.#text.charCodeAt(j) === NEWLINE;
1081
+ }
1082
+
1083
+ // -- Inline tokenizers --
1084
+
1085
+ #tokenize_inline(): void {
1086
+ const char_code = this.#text.charCodeAt(this.#index);
1087
+
1088
+ switch (char_code) {
1089
+ case BACKTICK:
1090
+ this.#tokenize_code();
1091
+ return;
1092
+ case ASTERISK:
1093
+ this.#tokenize_double_delimiter('**', 'bold');
1094
+ return;
1095
+ case UNDERSCORE:
1096
+ this.#tokenize_italic();
1097
+ return;
1098
+ case TILDE:
1099
+ this.#tokenize_double_delimiter('~~', 'strikethrough');
1100
+ return;
1101
+ case LEFT_BRACKET:
1102
+ this.#tokenize_markdown_link();
1103
+ return;
1104
+ case LEFT_ANGLE:
1105
+ this.#tokenize_tag();
1106
+ return;
1107
+ default:
1108
+ this.#tokenize_text();
1109
+ return;
1110
+ }
1111
+ }
1112
+
1113
+ #tokenize_code(): void {
1114
+ const start = this.#index;
1115
+ this.#index++; // consume `
1116
+
1117
+ // Find closing backtick, stop at newline or search boundary
1118
+ let content_end = -1;
1119
+ const search_limit = Math.min(this.#max_search_index, this.#text.length);
1120
+ for (let i = this.#index; i < search_limit; i++) {
1121
+ const char_code = this.#text.charCodeAt(i);
1122
+ if (char_code === BACKTICK) {
1123
+ content_end = i;
1124
+ break;
1125
+ }
1126
+ if (char_code === NEWLINE) break;
1127
+ }
1128
+
1129
+ if (content_end === -1) {
1130
+ // Unclosed backtick
1131
+ this.#emit_text('`', start);
1132
+ return;
1133
+ }
1134
+
1135
+ const content = this.#text.slice(this.#index, content_end);
1136
+
1137
+ // Empty inline code
1138
+ if (content.length === 0) {
1139
+ this.#index = content_end + 1;
1140
+ this.#emit_text('``', start);
1141
+ return;
1142
+ }
1143
+
1144
+ this.#index = content_end + 1;
1145
+ this.#tokens.push({type: 'code', content, start, end: this.#index});
1146
+ }
1147
+
1148
+ #tokenize_double_delimiter(delimiter: '**' | '~~', kind: 'bold' | 'strikethrough'): void {
1149
+ const start = this.#index;
1150
+
1151
+ // Check for the doubled delimiter
1152
+ if (!this.#match(delimiter)) {
1153
+ // Single char - text
1154
+ this.#index++;
1155
+ this.#emit_text(delimiter[0]!, start);
1156
+ return;
1157
+ }
1158
+
1159
+ // Find closing delimiter within current search boundary
1160
+ const search_end = Math.min(this.#max_search_index, this.#text.length);
1161
+ let close_index = this.#index_of(delimiter, start + 2);
1162
+ if (close_index !== -1 && close_index >= search_end) {
1163
+ close_index = -1;
1164
+ }
1165
+ if (close_index === -1) {
1166
+ // Unclosed
1167
+ this.#index += 2;
1168
+ this.#emit_text(delimiter, start);
1169
+ return;
1170
+ }
1171
+
1172
+ // Check for paragraph break between open and close
1173
+ if (this.#has_paragraph_break_between(start + 2, close_index)) {
1174
+ this.#index += 2;
1175
+ this.#emit_text(delimiter, start);
1176
+ return;
1177
+ }
1178
+
1179
+ // Emit open token, tokenize children up to close, emit close token
1180
+ this.#index += 2;
1181
+ const open_type = kind === 'bold' ? 'bold_open' : 'strikethrough_open';
1182
+ const close_type = kind === 'bold' ? 'bold_close' : 'strikethrough_close';
1183
+ const open_token_index = this.#tokens.length;
1184
+ this.#tokens.push({type: open_type, start, end: this.#index});
1185
+
1186
+ // Set search boundary for nested parsers
1187
+ const saved_max = this.#max_search_index;
1188
+ this.#max_search_index = close_index;
1189
+
1190
+ // Tokenize children inline content up to close_index
1191
+ while (this.#index < close_index) {
1192
+ if (this.#is_at_paragraph_break()) break;
1193
+ this.#tokenize_inline();
1194
+ }
1195
+
1196
+ this.#max_search_index = saved_max;
1197
+
1198
+ if (this.#index === close_index && this.#match(delimiter)) {
1199
+ // Check if empty
1200
+ const has_children = open_token_index < this.#tokens.length - 1;
1201
+
1202
+ if (!has_children) {
1203
+ // Empty - convert to text
1204
+ this.#tokens.splice(open_token_index, 1);
1205
+ this.#index = start;
1206
+ this.#index += 4;
1207
+ this.#emit_text(delimiter + delimiter, start);
1208
+ return;
1209
+ }
1210
+
1211
+ this.#tokens.push({type: close_type, start: close_index, end: close_index + 2});
1212
+ this.#index = close_index + 2;
1213
+ } else {
1214
+ // Didn't reach closing - convert opening to text
1215
+ this.#tokens[open_token_index] = {type: 'text', content: delimiter, start, end: start + 2};
1216
+ }
1217
+ }
1218
+
1219
+ #tokenize_italic(): void {
1220
+ const start = this.#index;
1221
+
1222
+ // Check opening word boundary
1223
+ if (!this.#is_at_word_boundary(this.#index, true, false)) {
1224
+ this.#index++;
1225
+ this.#emit_text('_', start);
1226
+ return;
1227
+ }
1228
+
1229
+ // Find closing delimiter within search boundary
1230
+ const search_end = Math.min(this.#max_search_index, this.#text.length);
1231
+ let close_index = this.#index_of('_', start + 1);
1232
+ if (close_index !== -1 && close_index >= search_end) {
1233
+ close_index = -1;
1234
+ }
1235
+ if (close_index === -1) {
1236
+ this.#index++;
1237
+ this.#emit_text('_', start);
1238
+ return;
1239
+ }
1240
+
1241
+ // Check closing word boundary
1242
+ if (!this.#is_at_word_boundary(close_index + 1, false, true)) {
1243
+ this.#index++;
1244
+ this.#emit_text('_', start);
1245
+ return;
1246
+ }
1247
+
1248
+ // Check for paragraph break between
1249
+ if (this.#has_paragraph_break_between(start + 1, close_index)) {
1250
+ this.#index++;
1251
+ this.#emit_text('_', start);
1252
+ return;
1253
+ }
1254
+
1255
+ // Emit open token
1256
+ this.#index++;
1257
+ const open_token_index = this.#tokens.length;
1258
+ this.#tokens.push({type: 'italic_open', start, end: this.#index});
1259
+
1260
+ // Set search boundary for nested parsers
1261
+ const saved_max = this.#max_search_index;
1262
+ this.#max_search_index = close_index;
1263
+
1264
+ // Tokenize children up to close_index
1265
+ while (this.#index < close_index) {
1266
+ if (this.#is_at_paragraph_break()) break;
1267
+ this.#tokenize_inline();
1268
+ }
1269
+
1270
+ this.#max_search_index = saved_max;
1271
+
1272
+ if (this.#index === close_index && this.#match('_')) {
1273
+ // Check if empty
1274
+ const has_children = open_token_index < this.#tokens.length - 1;
1275
+
1276
+ if (!has_children) {
1277
+ // Empty - convert to text
1278
+ this.#tokens.splice(open_token_index, 1);
1279
+ this.#index = start;
1280
+ this.#index += 2;
1281
+ this.#emit_text('__', start);
1282
+ return;
1283
+ }
1284
+
1285
+ this.#tokens.push({
1286
+ type: 'italic_close',
1287
+ start: close_index,
1288
+ end: close_index + 1,
1289
+ });
1290
+ this.#index = close_index + 1;
1291
+ } else {
1292
+ // Convert opening to text
1293
+ this.#tokens[open_token_index] = {
1294
+ type: 'text',
1295
+ content: '_',
1296
+ start,
1297
+ end: start + 1,
1298
+ };
1299
+ }
1300
+ }
1301
+
1302
+ #tokenize_markdown_link(): void {
1303
+ const start = this.#index;
1304
+ this.#index++; // consume [ (dispatch guarantees it)
1305
+
1306
+ // Emit link_text_open
1307
+ this.#tokens.push({type: 'link_text_open', start, end: this.#index});
1308
+
1309
+ // Tokenize children until ] — only `]` delimits link text; a bare `)`
1310
+ // is ordinary content (matching the streaming parser, which has no `)`
1311
+ // dispatch case — the reference scan finds its own `)` after `](`)
1312
+ while (this.#index < this.#text.length) {
1313
+ if (this.#text.charCodeAt(this.#index) === RIGHT_BRACKET) break;
1314
+ if (this.#is_at_paragraph_break()) break;
1315
+ this.#tokenize_inline();
1316
+ }
1317
+
1318
+ // Check for ]
1319
+ if (this.#index >= this.#text.length || this.#text.charCodeAt(this.#index) !== RIGHT_BRACKET) {
1320
+ // Revert - remove link_text_open and all children tokens added
1321
+ this.#revert_tokens_from_link_open(start);
1322
+ return;
1323
+ }
1324
+
1325
+ const bracket_close_start = this.#index;
1326
+ this.#index++; // consume ]
1327
+ this.#tokens.push({
1328
+ type: 'link_text_close',
1329
+ start: bracket_close_start,
1330
+ end: this.#index,
1331
+ });
1332
+
1333
+ // Check for (
1334
+ if (this.#index >= this.#text.length || this.#text.charCodeAt(this.#index) !== LEFT_PAREN) {
1335
+ this.#revert_tokens_from_link_open(start);
1336
+ return;
1337
+ }
1338
+ this.#index++; // consume (
1339
+
1340
+ // Find closing )
1341
+ const close_paren = this.#index_of(')', this.#index);
1342
+ if (close_paren === -1) {
1343
+ this.#revert_tokens_from_link_open(start);
1344
+ return;
1345
+ }
1346
+
1347
+ const reference = this.#text.slice(this.#index, close_paren);
1348
+
1349
+ // Validate reference
1350
+ if (!reference.trim()) {
1351
+ this.#revert_tokens_from_link_open(start);
1352
+ return;
1353
+ }
1354
+
1355
+ // Validate all characters
1356
+ for (let i = 0; i < reference.length; i++) {
1357
+ const char_code = reference.charCodeAt(i);
1358
+ if (!is_valid_path_char(char_code)) {
1359
+ this.#revert_tokens_from_link_open(start);
1360
+ return;
1361
+ }
1362
+ }
1363
+
1364
+ // Reject unsafe protocols (javascript:, data:, etc.) — `is_valid_path_char`
1365
+ // permits `:` so we need an explicit filter here.
1366
+ if (!mdz_is_safe_reference(reference)) {
1367
+ this.#revert_tokens_from_link_open(start);
1368
+ return;
1369
+ }
1370
+
1371
+ this.#index = close_paren + 1;
1372
+
1373
+ const link_type = mdz_is_url(reference) ? 'external' : 'internal';
1374
+
1375
+ this.#tokens.push({
1376
+ type: 'link_ref',
1377
+ reference,
1378
+ link_type,
1379
+ start: bracket_close_start + 1, // after ]
1380
+ end: this.#index,
1381
+ });
1382
+ }
1383
+
1384
+ #revert_tokens_from_link_open(start: number): void {
1385
+ // Find and remove link_text_open and all tokens after it
1386
+ let open_idx = -1;
1387
+ for (let i = this.#tokens.length - 1; i >= 0; i--) {
1388
+ if (this.#tokens[i]!.type === 'link_text_open' && this.#tokens[i]!.start === start) {
1389
+ open_idx = i;
1390
+ break;
1391
+ }
1392
+ }
1393
+ if (open_idx !== -1) {
1394
+ this.#tokens.splice(open_idx);
1395
+ }
1396
+ this.#index = start + 1;
1397
+ this.#emit_text('[', start);
1398
+ }
1399
+
1400
+ #tokenize_tag(): void {
1401
+ const start = this.#index;
1402
+ this.#index++; // consume <
1403
+
1404
+ // Tag name must start with a letter
1405
+ if (this.#index >= this.#text.length || !is_letter(this.#text.charCodeAt(this.#index))) {
1406
+ this.#emit_text('<', start);
1407
+ return;
1408
+ }
1409
+
1410
+ // Collect tag name
1411
+ const tag_name_start = this.#index;
1412
+ while (
1413
+ this.#index < this.#text.length &&
1414
+ is_tag_name_char(this.#text.charCodeAt(this.#index))
1415
+ ) {
1416
+ this.#index++;
1417
+ }
1418
+ // non-empty: the is_letter check above guarantees at least one name char
1419
+ const tag_name = this.#text.slice(tag_name_start, this.#index);
1420
+
1421
+ const first_char_code = tag_name.charCodeAt(0);
1422
+ const is_component = first_char_code >= A_UPPER && first_char_code <= Z_UPPER;
1423
+
1424
+ // Skip whitespace
1425
+ while (this.#index < this.#text.length && this.#text.charCodeAt(this.#index) === SPACE) {
1426
+ this.#index++;
1427
+ }
1428
+
1429
+ // Check for self-closing />
1430
+ if (
1431
+ this.#index + 1 < this.#text.length &&
1432
+ this.#text.charCodeAt(this.#index) === SLASH &&
1433
+ this.#text.charCodeAt(this.#index + 1) === RIGHT_ANGLE
1434
+ ) {
1435
+ this.#index += 2;
1436
+ this.#tokens.push({
1437
+ type: 'tag_self_close',
1438
+ name: tag_name,
1439
+ is_component,
1440
+ start,
1441
+ end: this.#index,
1442
+ });
1443
+ return;
1444
+ }
1445
+
1446
+ // Check for >
1447
+ if (this.#index >= this.#text.length || this.#text.charCodeAt(this.#index) !== RIGHT_ANGLE) {
1448
+ this.#index = start + 1;
1449
+ this.#emit_text('<', start);
1450
+ return;
1451
+ }
1452
+ this.#index++; // consume >
1453
+
1454
+ // Check for closing tag existence before committing —
1455
+ // must exist within search boundary and before any paragraph break.
1456
+ // A closer at exactly `#max_search_index` is allowed: that bound can
1457
+ // only coincide with a closing-tag occurrence when an enclosing
1458
+ // same-name tag set it (the nesting handoff — this tag consumes the
1459
+ // closer and the enclosing tag re-verifies its own next iteration);
1460
+ // every other bound (heading EOL, list run end, delimiter close
1461
+ // position) points at a character that can't begin a closing tag.
1462
+ const closing_tag = `</${tag_name}>`;
1463
+ const search_limit = Math.min(this.#max_search_index, this.#text.length);
1464
+ const closing_tag_pos = this.#index_of(closing_tag, this.#index);
1465
+ if (closing_tag_pos === -1 || closing_tag_pos > search_limit) {
1466
+ this.#index = start + 1;
1467
+ this.#emit_text('<', start);
1468
+ return;
1469
+ }
1470
+ if (this.#has_paragraph_break_between(this.#index, closing_tag_pos)) {
1471
+ this.#index = start + 1;
1472
+ this.#emit_text('<', start);
1473
+ return;
1474
+ }
1475
+
1476
+ // Emit tag_open
1477
+ const open_token_index = this.#tokens.length;
1478
+ this.#tokens.push({type: 'tag_open', name: tag_name, is_component, start, end: this.#index});
1479
+
1480
+ // Tokenize children until the closing tag. Each iteration narrows
1481
+ // `#max_search_index` to the next closer so a child's delimiter scan
1482
+ // can never cross it (`` <b>`a</b>x` `` must not swallow `</b>` into
1483
+ // the code span). Recomputed per iteration because a nested same-name
1484
+ // tag legitimately consumes the nearest closer (`<b>x<b>y</b></b>`).
1485
+ const saved_max = this.#max_search_index;
1486
+ while (this.#index < this.#text.length) {
1487
+ if (this.#match(closing_tag)) {
1488
+ this.#max_search_index = saved_max;
1489
+ const close_start = this.#index;
1490
+ this.#index += closing_tag.length;
1491
+ this.#tokens.push({
1492
+ type: 'tag_close',
1493
+ name: tag_name,
1494
+ start: close_start,
1495
+ end: this.#index,
1496
+ });
1497
+ return;
1498
+ }
1499
+ const next_close = this.#index_of(closing_tag, this.#index);
1500
+ if (
1501
+ next_close === -1 ||
1502
+ next_close > Math.min(saved_max, this.#text.length) ||
1503
+ this.#has_paragraph_break_between(this.#index, next_close)
1504
+ ) {
1505
+ break; // a nested same-name tag consumed the verified closer
1506
+ }
1507
+ this.#max_search_index = next_close;
1508
+ this.#tokenize_inline();
1509
+ }
1510
+
1511
+ // No valid closer left — revert: drop the open token and all consumed
1512
+ // children tokens, emit `<` as text, and re-lex from the next char
1513
+ // (the `#revert_tokens_from_link_open` pattern; the old fallthrough
1514
+ // left a dangling `tag_open` whose parser fallback discarded content)
1515
+ this.#max_search_index = saved_max;
1516
+ this.#tokens.splice(open_token_index);
1517
+ this.#index = start + 1;
1518
+ this.#emit_text('<', start);
1519
+ }
1520
+
1521
+ #tokenize_text(): void {
1522
+ const start = this.#index;
1523
+
1524
+ // Check for URL or internal path at current position
1525
+ if (this.#is_at_url()) {
1526
+ this.#tokenize_auto_link_url();
1527
+ return;
1528
+ }
1529
+ if (is_at_absolute_path(this.#text, this.#index)) {
1530
+ this.#tokenize_auto_link_internal();
1531
+ return;
1532
+ }
1533
+ if (is_at_relative_path(this.#text, this.#index)) {
1534
+ this.#tokenize_auto_link_internal();
1535
+ return;
1536
+ }
1537
+
1538
+ // in a list-item run, continuation-line structural indent strips from
1539
+ // content: parts accumulate around each stripped region
1540
+ let parts: Array<string> | null = null;
1541
+ let part_start = start;
1542
+
1543
+ while (this.#index < this.#text.length) {
1544
+ const char_code = this.#text.charCodeAt(this.#index);
1545
+
1546
+ // Stop at special characters
1547
+ if (
1548
+ char_code === BACKTICK ||
1549
+ char_code === ASTERISK ||
1550
+ char_code === UNDERSCORE ||
1551
+ char_code === TILDE ||
1552
+ char_code === LEFT_BRACKET ||
1553
+ char_code === RIGHT_BRACKET ||
1554
+ char_code === LEFT_ANGLE
1555
+ ) {
1556
+ break;
1557
+ }
1558
+
1559
+ if (char_code === NEWLINE) {
1560
+ if (this.#list_run_strip) {
1561
+ if (this.#index >= this.#max_search_index) break; // the run's terminator
1562
+ // soft break: keep the newline, strip the next line's leading
1563
+ // whitespace (structural indent, never content)
1564
+ this.#index++;
1565
+ const ws_start = this.#index;
1566
+ let i = ws_start;
1567
+ while (i < this.#text.length && is_line_whitespace(this.#text.charCodeAt(i))) {
1568
+ i++;
1569
+ }
1570
+ if (i > ws_start) {
1571
+ (parts ??= []).push(this.#text.slice(part_start, ws_start));
1572
+ part_start = i;
1573
+ this.#index = i;
1574
+ }
1575
+ continue;
1576
+ }
1577
+
1578
+ // Check for paragraph break
1579
+ if (this.#is_at_paragraph_break()) break;
1580
+
1581
+ // When next line could start a block element, consume the newline and
1582
+ // stop (`1` covers ordered list markers — only `1.` interrupts)
1583
+ const next_i = this.#index + 1;
1584
+ if (next_i < this.#text.length) {
1585
+ const next_char = this.#text.charCodeAt(next_i);
1586
+ if (
1587
+ next_char === HASH ||
1588
+ next_char === HYPHEN ||
1589
+ next_char === BACKTICK ||
1590
+ next_char === ONE ||
1591
+ next_char === RIGHT_ANGLE
1592
+ ) {
1593
+ this.#index++; // consume the newline
1594
+ break;
1595
+ }
1596
+ }
1597
+ }
1598
+
1599
+ // Check for URL or internal path mid-text (char code guard avoids startsWith on every char)
1600
+ if (
1601
+ ((char_code === 104 /* h */ || char_code === 72) /* H */ && this.#is_at_url()) ||
1602
+ (char_code === SLASH && is_at_absolute_path(this.#text, this.#index)) ||
1603
+ (char_code === PERIOD && is_at_relative_path(this.#text, this.#index))
1604
+ ) {
1605
+ break;
1606
+ }
1607
+
1608
+ this.#index++;
1609
+ }
1610
+
1611
+ // Ensure we always consume at least one character
1612
+ if (this.#index === start && this.#index < this.#text.length) {
1613
+ this.#index++;
1614
+ }
1615
+
1616
+ const content =
1617
+ parts === null
1618
+ ? this.#text.slice(start, this.#index)
1619
+ : parts.join('') + this.#text.slice(part_start, this.#index);
1620
+ this.#tokens.push({type: 'text', content, start, end: this.#index});
1621
+ }
1622
+
1623
+ // -- Auto-link tokenizers --
1624
+
1625
+ #tokenize_auto_link_url(): void {
1626
+ const start = this.#index;
1627
+
1628
+ // Consume protocol (case-insensitive match; original casing preserved in reference)
1629
+ this.#index += match_url_prefix_case_insensitive(this.#text, this.#index);
1630
+
1631
+ // Collect URL characters
1632
+ while (this.#index < this.#text.length) {
1633
+ const char_code = this.#text.charCodeAt(this.#index);
1634
+ if (char_code === SPACE || char_code === NEWLINE || !is_valid_path_char(char_code)) {
1635
+ break;
1636
+ }
1637
+ this.#index++;
1638
+ }
1639
+
1640
+ let reference = this.#text.slice(start, this.#index);
1641
+ reference = mdz_trim_url_punctuation(reference);
1642
+ this.#index = start + reference.length;
1643
+
1644
+ this.#tokens.push({
1645
+ type: 'autolink',
1646
+ reference,
1647
+ link_type: 'external',
1648
+ start,
1649
+ end: this.#index,
1650
+ });
1651
+ }
1652
+
1653
+ #tokenize_auto_link_internal(): void {
1654
+ const start = this.#index;
1655
+
1656
+ // Collect path characters
1657
+ while (this.#index < this.#text.length) {
1658
+ const char_code = this.#text.charCodeAt(this.#index);
1659
+ if (char_code === SPACE || char_code === NEWLINE || !is_valid_path_char(char_code)) {
1660
+ break;
1661
+ }
1662
+ this.#index++;
1663
+ }
1664
+
1665
+ let reference = this.#text.slice(start, this.#index);
1666
+ reference = mdz_trim_url_punctuation(reference);
1667
+ this.#index = start + reference.length;
1668
+
1669
+ this.#tokens.push({
1670
+ type: 'autolink',
1671
+ reference,
1672
+ link_type: 'internal',
1673
+ start,
1674
+ end: this.#index,
1675
+ });
1676
+ }
1677
+
1678
+ // -- Helper methods --
1679
+
1680
+ #emit_text(content: string, start: number): void {
1681
+ this.#tokens.push({type: 'text', content, start, end: start + content.length});
1682
+ }
1683
+
1684
+ #has_paragraph_break_between(from: number, to: number): boolean {
1685
+ // memoized search — a per-candidate char loop would rescan the same
1686
+ // region for every delimiter/tag candidate in a long paragraph. The
1687
+ // break can't straddle `to`: `to` is a found delimiter/tag position
1688
+ // (never whitespace or a newline), so a break starting before it
1689
+ // completes before it.
1690
+ const idx = this.#next_paragraph_break(from);
1691
+ return idx !== -1 && idx < to;
1692
+ }
1693
+
1694
+ /**
1695
+ * Position of the next paragraph break at or after `from` — a newline
1696
+ * whose following line is blank (whitespace-only) — or `-1`. Memoized like
1697
+ * `#index_of`: the text is immutable, so the first break at or after a
1698
+ * scanned origin stays valid for any later origin it still follows.
1699
+ */
1700
+ #next_paragraph_break(from: number): number {
1701
+ const memo = this.#break_memo;
1702
+ if (memo !== null && memo.from <= from && (memo.result === -1 || memo.result >= from)) {
1703
+ return memo.result;
1704
+ }
1705
+ let result = -1;
1706
+ let i = this.#text.indexOf('\n', from);
1707
+ while (i !== -1) {
1708
+ let j = i + 1;
1709
+ while (j < this.#text.length && is_line_whitespace(this.#text.charCodeAt(j))) {
1710
+ j++;
1711
+ }
1712
+ if (j < this.#text.length && this.#text.charCodeAt(j) === NEWLINE) {
1713
+ result = i;
1714
+ break;
1715
+ }
1716
+ // no break here; the next candidate newline is past the scanned run
1717
+ i = this.#text.indexOf('\n', j);
1718
+ }
1719
+ if (memo === null) {
1720
+ this.#break_memo = {from, result};
1721
+ } else {
1722
+ memo.from = from;
1723
+ memo.result = result;
1724
+ }
1725
+ return result;
1726
+ }
1727
+
1728
+ #is_at_url(): boolean {
1729
+ // word boundary check: skip when preceded by [A-Za-z0-9] — `xhttps://...`
1730
+ // is plain text, matching the streaming parser (false negatives over
1731
+ // false positives)
1732
+ if (this.#index > 0 && is_word_char(this.#text.charCodeAt(this.#index - 1))) {
1733
+ return false;
1734
+ }
1735
+ // Scheme matching is case-insensitive (RFC 3986).
1736
+ const prefix_len = match_url_prefix_case_insensitive(this.#text, this.#index);
1737
+ if (prefix_len === 0) return false;
1738
+ if (this.#index + prefix_len >= this.#text.length) return false;
1739
+ const next_char = this.#text.charCodeAt(this.#index + prefix_len);
1740
+ return next_char !== SPACE && next_char !== NEWLINE;
1741
+ }
1742
+
1743
+ /**
1744
+ * Word-boundary check for the italic delimiter (the only delimiter with
1745
+ * boundary rules — `**`/`~~` pair anywhere). `_` is self-opaque — it
1746
+ * counts as a word char in its own boundary checks, so runs like
1747
+ * `__init__` never form open/close pairs — while other delimiters stay
1748
+ * transparent (so `_~~x~~_` nests).
1749
+ */
1750
+ #is_at_word_boundary(index: number, check_before: boolean, check_after: boolean): boolean {
1751
+ if (check_before && index > 0) {
1752
+ const prev = this.#text.charCodeAt(index - 1);
1753
+ if (is_word_char(prev) || prev === UNDERSCORE) return false;
1754
+ }
1755
+ if (check_after && index < this.#text.length) {
1756
+ const next = this.#text.charCodeAt(index);
1757
+ if (is_word_char(next) || next === UNDERSCORE) return false;
1758
+ }
1759
+ return true;
1760
+ }
1761
+ }