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