@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.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/dist/Mdz.svelte +39 -0
- package/dist/Mdz.svelte.d.ts +17 -0
- package/dist/Mdz.svelte.d.ts.map +1 -0
- package/dist/MdzNodeView.svelte +140 -0
- package/dist/MdzNodeView.svelte.d.ts +9 -0
- package/dist/MdzNodeView.svelte.d.ts.map +1 -0
- package/dist/MdzPrecompiled.svelte +33 -0
- package/dist/MdzPrecompiled.svelte.d.ts +17 -0
- package/dist/MdzPrecompiled.svelte.d.ts.map +1 -0
- package/dist/MdzRoot.svelte +40 -0
- package/dist/MdzRoot.svelte.d.ts +14 -0
- package/dist/MdzRoot.svelte.d.ts.map +1 -0
- package/dist/MdzStream.svelte +37 -0
- package/dist/MdzStream.svelte.d.ts +18 -0
- package/dist/MdzStream.svelte.d.ts.map +1 -0
- package/dist/MdzStreamNodeView.svelte +150 -0
- package/dist/MdzStreamNodeView.svelte.d.ts +9 -0
- package/dist/MdzStreamNodeView.svelte.d.ts.map +1 -0
- package/dist/context_helpers.d.ts +23 -0
- package/dist/context_helpers.d.ts.map +1 -0
- package/dist/context_helpers.js +19 -0
- package/dist/mdz.d.ts +152 -0
- package/dist/mdz.d.ts.map +1 -0
- package/dist/mdz.js +61 -0
- package/dist/mdz_contexts.d.ts +106 -0
- package/dist/mdz_contexts.d.ts.map +1 -0
- package/dist/mdz_contexts.js +42 -0
- package/dist/mdz_helpers.d.ts +321 -0
- package/dist/mdz_helpers.d.ts.map +1 -0
- package/dist/mdz_helpers.js +668 -0
- package/dist/mdz_lexer.d.ts +123 -0
- package/dist/mdz_lexer.d.ts.map +1 -0
- package/dist/mdz_lexer.js +1405 -0
- package/dist/mdz_opcodes.d.ts +193 -0
- package/dist/mdz_opcodes.d.ts.map +1 -0
- package/dist/mdz_opcodes.js +14 -0
- package/dist/mdz_opcodes_to_nodes.d.ts +26 -0
- package/dist/mdz_opcodes_to_nodes.d.ts.map +1 -0
- package/dist/mdz_opcodes_to_nodes.js +410 -0
- package/dist/mdz_stream_parser.d.ts +109 -0
- package/dist/mdz_stream_parser.d.ts.map +1 -0
- package/dist/mdz_stream_parser.js +699 -0
- package/dist/mdz_stream_parser_block.d.ts +99 -0
- package/dist/mdz_stream_parser_block.d.ts.map +1 -0
- package/dist/mdz_stream_parser_block.js +437 -0
- package/dist/mdz_stream_parser_inline.d.ts +98 -0
- package/dist/mdz_stream_parser_inline.d.ts.map +1 -0
- package/dist/mdz_stream_parser_inline.js +481 -0
- package/dist/mdz_stream_parser_link.d.ts +29 -0
- package/dist/mdz_stream_parser_link.d.ts.map +1 -0
- package/dist/mdz_stream_parser_link.js +227 -0
- package/dist/mdz_stream_parser_list.d.ts +106 -0
- package/dist/mdz_stream_parser_list.d.ts.map +1 -0
- package/dist/mdz_stream_parser_list.js +438 -0
- package/dist/mdz_stream_parser_state.d.ts +476 -0
- package/dist/mdz_stream_parser_state.d.ts.map +1 -0
- package/dist/mdz_stream_parser_state.js +651 -0
- package/dist/mdz_stream_parser_text.d.ts +25 -0
- package/dist/mdz_stream_parser_text.d.ts.map +1 -0
- package/dist/mdz_stream_parser_text.js +67 -0
- package/dist/mdz_stream_parser_url.d.ts +76 -0
- package/dist/mdz_stream_parser_url.d.ts.map +1 -0
- package/dist/mdz_stream_parser_url.js +328 -0
- package/dist/mdz_stream_state.svelte.d.ts +53 -0
- package/dist/mdz_stream_state.svelte.d.ts.map +1 -0
- package/dist/mdz_stream_state.svelte.js +360 -0
- package/dist/mdz_to_svelte.d.ts +71 -0
- package/dist/mdz_to_svelte.d.ts.map +1 -0
- package/dist/mdz_to_svelte.js +127 -0
- package/dist/mdz_token_parser.d.ts +20 -0
- package/dist/mdz_token_parser.d.ts.map +1 -0
- package/dist/mdz_token_parser.js +449 -0
- package/dist/svelte_preprocess_mdz.d.ts +80 -0
- package/dist/svelte_preprocess_mdz.d.ts.map +1 -0
- package/dist/svelte_preprocess_mdz.js +543 -0
- package/dist/tsdoc_mdz.d.ts +45 -0
- package/dist/tsdoc_mdz.d.ts.map +1 -0
- package/dist/tsdoc_mdz.js +88 -0
- package/package.json +131 -0
- package/src/lib/context_helpers.ts +52 -0
- package/src/lib/mdz.ts +190 -0
- package/src/lib/mdz_contexts.ts +99 -0
- package/src/lib/mdz_helpers.ts +727 -0
- package/src/lib/mdz_lexer.ts +1761 -0
- package/src/lib/mdz_opcodes.ts +227 -0
- package/src/lib/mdz_opcodes_to_nodes.ts +470 -0
- package/src/lib/mdz_stream_parser.ts +766 -0
- package/src/lib/mdz_stream_parser_block.ts +490 -0
- package/src/lib/mdz_stream_parser_inline.ts +527 -0
- package/src/lib/mdz_stream_parser_link.ts +283 -0
- package/src/lib/mdz_stream_parser_list.ts +525 -0
- package/src/lib/mdz_stream_parser_state.ts +957 -0
- package/src/lib/mdz_stream_parser_text.ts +95 -0
- package/src/lib/mdz_stream_parser_url.ts +386 -0
- package/src/lib/mdz_stream_state.svelte.ts +383 -0
- package/src/lib/mdz_to_svelte.ts +213 -0
- package/src/lib/mdz_token_parser.ts +544 -0
- package/src/lib/svelte_preprocess_mdz.ts +777 -0
- 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
|
+
}
|