@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,668 @@
1
+ /**
2
+ * Shared constants and pure helper functions for mdz parsers.
3
+ *
4
+ * Used by both the synchronous parser (`mdz_lexer.ts` + `mdz_token_parser.ts`)
5
+ * and the streaming parser (`mdz_stream_parser.ts`).
6
+ *
7
+ * @module
8
+ */
9
+ // Character codes for performance
10
+ /** @nodocs */
11
+ export const BACKTICK = 96; // `
12
+ /** @nodocs */
13
+ export const ASTERISK = 42; // *
14
+ /** @nodocs */
15
+ export const UNDERSCORE = 95; // _
16
+ /** @nodocs */
17
+ export const TILDE = 126; // ~
18
+ /** @nodocs */
19
+ export const NEWLINE = 10; // \n
20
+ /** @nodocs */
21
+ export const HYPHEN = 45; // -
22
+ /** @nodocs */
23
+ export const HASH = 35; // #
24
+ /** @nodocs */
25
+ export const SPACE = 32; // (space)
26
+ /** @nodocs */
27
+ export const TAB = 9; // \t
28
+ /** @nodocs */
29
+ export const CARRIAGE_RETURN = 13; // \r
30
+ /** @nodocs */
31
+ export const LEFT_ANGLE = 60; // <
32
+ /** @nodocs */
33
+ export const RIGHT_ANGLE = 62; // >
34
+ /** @nodocs */
35
+ export const SLASH = 47; // /
36
+ /** @nodocs */
37
+ export const LEFT_BRACKET = 91; // [
38
+ /** @nodocs */
39
+ export const RIGHT_BRACKET = 93; // ]
40
+ /** @nodocs */
41
+ export const LEFT_PAREN = 40; // (
42
+ /** @nodocs */
43
+ export const RIGHT_PAREN = 41; // )
44
+ /** @nodocs */
45
+ export const COLON = 58; // :
46
+ /** @nodocs */
47
+ export const PERIOD = 46; // .
48
+ /** @nodocs */
49
+ export const COMMA = 44; // ,
50
+ /** @nodocs */
51
+ export const SEMICOLON = 59; // ;
52
+ /** @nodocs */
53
+ export const EXCLAMATION = 33; // !
54
+ /** @nodocs */
55
+ export const QUESTION = 63; // ?
56
+ // RFC 3986 URI characters
57
+ /** @nodocs */
58
+ export const DOLLAR = 36; // $
59
+ /** @nodocs */
60
+ export const PERCENT = 37; // %
61
+ /** @nodocs */
62
+ export const AMPERSAND = 38; // &
63
+ /** @nodocs */
64
+ export const APOSTROPHE = 39; // '
65
+ /** @nodocs */
66
+ export const PLUS = 43; // +
67
+ /** @nodocs */
68
+ export const EQUALS = 61; // =
69
+ /** @nodocs */
70
+ export const AT = 64; // @
71
+ // Character ranges
72
+ /** @nodocs */
73
+ export const A_UPPER = 65; // A
74
+ /** @nodocs */
75
+ export const Z_UPPER = 90; // Z
76
+ /** @nodocs */
77
+ export const A_LOWER = 97; // a
78
+ /** @nodocs */
79
+ export const Z_LOWER = 122; // z
80
+ /** @nodocs */
81
+ export const ZERO = 48; // 0
82
+ /** @nodocs */
83
+ export const ONE = 49; // 1
84
+ /** @nodocs */
85
+ export const NINE = 57; // 9
86
+ // mdz specification constants
87
+ /** @nodocs */
88
+ export const HR_HYPHEN_COUNT = 3; // Horizontal rule requires exactly 3 hyphens
89
+ /** @nodocs */
90
+ export const MIN_CODEBLOCK_BACKTICKS = 3; // Code blocks require minimum 3 backticks
91
+ /** @nodocs */
92
+ export const MAX_LIST_NUMBER_DIGITS = 9; // Ordered list markers cap at 9 digits (CommonMark's cap)
93
+ /** @nodocs */
94
+ export const MAX_HEADING_LEVEL = 6; // Headings support levels 1-6
95
+ /** @nodocs */
96
+ export const HTTPS_PREFIX_LENGTH = 8; // Length of "https://"
97
+ /** @nodocs */
98
+ export const HTTP_PREFIX_LENGTH = 7; // Length of "http://"
99
+ /** @nodocs */
100
+ export const H_LOWER = 104; // h
101
+ /** @nodocs */
102
+ export const H_UPPER = 72; // H
103
+ /**
104
+ * Whether a char code is whitespace within a line — space, tab, or `\r` —
105
+ * everything but the newline itself. A line consisting only of these counts
106
+ * as blank (paragraph breaks), and trailing runs of them are tolerated but
107
+ * never significant (HR and fence lines). `\r` is included so CRLF input
108
+ * gets blank-line and end-of-line behavior for free; mdz does no other CRLF
109
+ * handling.
110
+ *
111
+ * @nodocs
112
+ */
113
+ export const is_line_whitespace = (char_code) => char_code === SPACE || char_code === TAB || char_code === CARRIAGE_RETURN;
114
+ /**
115
+ * Whether `text` contains any non-whitespace character — the
116
+ * whitespace-only-paragraph drop shared by both parsers. Hot path in the
117
+ * streaming parser (called from `emit` on every text emission); the char
118
+ * loop avoids the regex allocation `/\S/.test()` incurs per call.
119
+ *
120
+ * Deliberately ASCII-only (space, tab, LF, CR, FF, VT), matching the
121
+ * grammar's blank-line definition: a line of Unicode-only whitespace
122
+ * (U+00A0 NBSP, U+3000, etc.) is content, not blank — CommonMark agrees.
123
+ * `/\S/` and `String.prototype.trim()` would recognize Unicode whitespace
124
+ * and silently drop such paragraphs.
125
+ *
126
+ * @nodocs
127
+ */
128
+ export const has_non_whitespace = (text) => {
129
+ for (let i = 0; i < text.length; i++) {
130
+ const c = text.charCodeAt(i);
131
+ // 0x09=tab, 0x0a=LF, 0x0b=VT, 0x0c=FF, 0x0d=CR, 0x20=space
132
+ if (c !== 0x20 && (c < 0x09 || c > 0x0d))
133
+ return true;
134
+ }
135
+ return false;
136
+ };
137
+ /**
138
+ * Match a blockquote prefix at `i` (the position of a `>`). Shared by both
139
+ * parsers — the sync lexer calls with `forced` true (its text is complete);
140
+ * the streaming parser gets `'pending'` when the buffer ends before the
141
+ * prefix can resolve.
142
+ *
143
+ * Returns null when the line has no valid prefix: a `>` followed directly by
144
+ * content (`>a`), a tab instead of the marker space (`>\ta`), or a tight run
145
+ * with trailing content (`>>x`). The backtrack pin: a `>` not followed by
146
+ * space, end-of-line, or a continuing run is content, and the separator space
147
+ * before it re-serves as the terminator — `> >x` is a depth-1 prefix with
148
+ * content `>x`. A `>` followed by only whitespace to the line end is a valid
149
+ * bare prefix (trailing whitespace tolerated, never significant).
150
+ *
151
+ * @nodocs
152
+ */
153
+ export const mdz_match_blockquote_prefix = (text, i, forced) => {
154
+ let depth = 0;
155
+ let pos = i;
156
+ while (true) {
157
+ // only reachable at entry — separators are consumed only when a `>` follows
158
+ if (text.charCodeAt(pos) !== RIGHT_ANGLE)
159
+ return null;
160
+ depth++;
161
+ pos++;
162
+ if (pos >= text.length) {
163
+ return forced ? { depth, content_start: pos } : 'pending';
164
+ }
165
+ const c = text.charCodeAt(pos);
166
+ if (c === RIGHT_ANGLE)
167
+ continue; // tight run
168
+ if (c === SPACE) {
169
+ if (pos + 1 >= text.length) {
170
+ // could be a separator (next chunk starts with `>`) or the terminator
171
+ return forced ? { depth, content_start: pos + 1 } : 'pending';
172
+ }
173
+ if (text.charCodeAt(pos + 1) === RIGHT_ANGLE) {
174
+ pos++; // separator — continue the run at the next `>`
175
+ continue;
176
+ }
177
+ return { depth, content_start: pos + 1 }; // terminating space
178
+ }
179
+ if (c === NEWLINE) {
180
+ return { depth, content_start: pos }; // bare prefix line
181
+ }
182
+ if (is_line_whitespace(c)) {
183
+ // tab/CR is not the marker space — valid only as tolerated trailing
184
+ // whitespace on a bare prefix line
185
+ let j = pos;
186
+ while (j < text.length && is_line_whitespace(text.charCodeAt(j))) {
187
+ j++;
188
+ }
189
+ if (j >= text.length) {
190
+ return forced ? { depth, content_start: pos } : 'pending';
191
+ }
192
+ if (text.charCodeAt(j) === NEWLINE) {
193
+ return { depth, content_start: pos };
194
+ }
195
+ // trailing content after non-space whitespace — fall through to backtrack
196
+ }
197
+ // invalid char directly after a `>`: this `>` is content; the separator
198
+ // space before it (if any) re-serves as the terminator
199
+ if (depth >= 2 && text.charCodeAt(pos - 2) === SPACE) {
200
+ return { depth: depth - 1, content_start: pos - 1 };
201
+ }
202
+ return null;
203
+ }
204
+ };
205
+ /**
206
+ * Whether the line at a quote prefix's `content_start` has non-whitespace
207
+ * content before its end — the no-empty-start probe for quote opens
208
+ * (contentless prefix lines never start a quote). `'pending'` when the buffer
209
+ * ends inside the trailing whitespace run while streaming.
210
+ *
211
+ * @nodocs
212
+ */
213
+ export const mdz_blockquote_line_has_content = (text, content_start, forced) => {
214
+ let i = content_start;
215
+ while (i < text.length && is_line_whitespace(text.charCodeAt(i))) {
216
+ i++;
217
+ }
218
+ if (i >= text.length)
219
+ return forced ? false : 'pending';
220
+ return text.charCodeAt(i) !== NEWLINE;
221
+ };
222
+ /**
223
+ * The one-level strip width for a quote line: the first `>` plus its single
224
+ * following space when present. Stripping one level leaves any deeper prefix
225
+ * in the content for the nested document to parse — `>> a` strips to `> a`.
226
+ *
227
+ * @nodocs
228
+ */
229
+ export const mdz_blockquote_strip_width = (text, i) => i + 1 < text.length && text.charCodeAt(i + 1) === SPACE ? 2 : 1;
230
+ /**
231
+ * Map a position in a blockquote's stripped inner document back to its
232
+ * source offset. Each segment covers one quote line's content plus its
233
+ * terminating newline — contiguous in both coordinate spaces, so the map is
234
+ * affine per segment. A binary search for the rightmost segment at or before
235
+ * `p` always lands correctly, including the end-exclusive position past the
236
+ * final line.
237
+ *
238
+ * @nodocs
239
+ */
240
+ export const mdz_remap_segments = (segments, p) => {
241
+ let lo = 0;
242
+ let hi = segments.length - 1;
243
+ while (lo < hi) {
244
+ const mid = (lo + hi + 1) >> 1;
245
+ if (segments[mid].inner_start <= p) {
246
+ lo = mid;
247
+ }
248
+ else {
249
+ hi = mid - 1;
250
+ }
251
+ }
252
+ const seg = segments[lo];
253
+ return seg.source_start + (p - seg.inner_start);
254
+ };
255
+ /**
256
+ * Lowercase an ASCII letter code; passes other chars through.
257
+ * Mirrors the `[A-Z] → [a-z]` shift without touching non-ASCII.
258
+ *
259
+ * @nodocs
260
+ */
261
+ export const ascii_to_lower = (char_code) => char_code >= A_UPPER && char_code <= Z_UPPER ? char_code + 32 : char_code;
262
+ /**
263
+ * Case-insensitive URL scheme prefix check. Returns the prefix length
264
+ * (8 for `https://`, 7 for `http://`) or 0 if neither matches at `pos`.
265
+ *
266
+ * URI schemes are case-insensitive per RFC 3986 §3.1, and mobile keyboards
267
+ * routinely auto-capitalize the first letter of a sentence, producing
268
+ * `Https://` etc. The original case is preserved in the emitted reference —
269
+ * browsers normalize schemes themselves.
270
+ *
271
+ * @nodocs
272
+ */
273
+ export const match_url_prefix_case_insensitive = (s, pos) => {
274
+ if (pos + HTTP_PREFIX_LENGTH > s.length)
275
+ return 0;
276
+ // h | H
277
+ if (ascii_to_lower(s.charCodeAt(pos)) !== H_LOWER)
278
+ return 0;
279
+ // 'ttps://' (7 chars) or 'ttp://' (6 chars)
280
+ if (ascii_to_lower(s.charCodeAt(pos + 1)) === 116 /* t */ &&
281
+ ascii_to_lower(s.charCodeAt(pos + 2)) === 116 /* t */ &&
282
+ ascii_to_lower(s.charCodeAt(pos + 3)) === 112 /* p */) {
283
+ if (pos + HTTPS_PREFIX_LENGTH <= s.length &&
284
+ ascii_to_lower(s.charCodeAt(pos + 4)) === 115 /* s */ &&
285
+ s.charCodeAt(pos + 5) === COLON &&
286
+ s.charCodeAt(pos + 6) === SLASH &&
287
+ s.charCodeAt(pos + 7) === SLASH) {
288
+ return HTTPS_PREFIX_LENGTH;
289
+ }
290
+ if (s.charCodeAt(pos + 4) === COLON &&
291
+ s.charCodeAt(pos + 5) === SLASH &&
292
+ s.charCodeAt(pos + 6) === SLASH) {
293
+ return HTTP_PREFIX_LENGTH;
294
+ }
295
+ }
296
+ return 0;
297
+ };
298
+ /**
299
+ * Check if character code is a letter (A-Z, a-z).
300
+ *
301
+ * @nodocs
302
+ */
303
+ export const is_letter = (char_code) => (char_code >= A_UPPER && char_code <= Z_UPPER) || (char_code >= A_LOWER && char_code <= Z_LOWER);
304
+ /**
305
+ * Check if character code is valid for tag name (letter, number, hyphen, underscore).
306
+ *
307
+ * @nodocs
308
+ */
309
+ export const is_tag_name_char = (char_code) => is_letter(char_code) ||
310
+ (char_code >= ZERO && char_code <= NINE) ||
311
+ char_code === HYPHEN ||
312
+ char_code === UNDERSCORE;
313
+ /**
314
+ * Check if character is part of a word for word boundary detection.
315
+ * Used to prevent intraword emphasis with the `_` delimiter.
316
+ *
317
+ * Only alphanumeric characters (A-Z, a-z, 0-9) are word characters.
318
+ * Formatting delimiters (`*`, `_`, `~`) fall outside all three ranges,
319
+ * so they're naturally excluded without explicit checks.
320
+ *
321
+ * This prevents false positives with snake_case identifiers while allowing
322
+ * adjacent formatting like `**bold**_italic_`.
323
+ *
324
+ * @nodocs
325
+ */
326
+ export const is_word_char = (char_code) => (char_code >= A_UPPER && char_code <= Z_UPPER) ||
327
+ (char_code >= A_LOWER && char_code <= Z_LOWER) ||
328
+ (char_code >= ZERO && char_code <= NINE);
329
+ /**
330
+ * Lookup table for valid URI path characters per RFC 3986.
331
+ * Replaces a 16-comparison chain with a single array access.
332
+ *
333
+ * Valid characters:
334
+ * - unreserved: A-Z a-z 0-9 - . _ ~
335
+ * - sub-delims: ! $ & ' ( ) * + , ; =
336
+ * - path allowed: : @
337
+ * - separators: / ? #
338
+ * - percent-encoding: %
339
+ */
340
+ const PATH_CHAR_TABLE = (() => {
341
+ const t = new Uint8Array(128);
342
+ for (let i = A_UPPER; i <= Z_UPPER; i++)
343
+ t[i] = 1;
344
+ for (let i = A_LOWER; i <= Z_LOWER; i++)
345
+ t[i] = 1;
346
+ for (let i = ZERO; i <= NINE; i++)
347
+ t[i] = 1;
348
+ // unreserved: - . _ ~
349
+ t[HYPHEN] = 1;
350
+ t[PERIOD] = 1;
351
+ t[UNDERSCORE] = 1;
352
+ t[TILDE] = 1;
353
+ // sub-delims: ! $ & ' ( ) * + , ; =
354
+ t[EXCLAMATION] = 1;
355
+ t[DOLLAR] = 1;
356
+ t[AMPERSAND] = 1;
357
+ t[APOSTROPHE] = 1;
358
+ t[LEFT_PAREN] = 1;
359
+ t[RIGHT_PAREN] = 1;
360
+ t[ASTERISK] = 1;
361
+ t[PLUS] = 1;
362
+ t[COMMA] = 1;
363
+ t[SEMICOLON] = 1;
364
+ t[EQUALS] = 1;
365
+ // path allowed: : @
366
+ t[COLON] = 1;
367
+ t[AT] = 1;
368
+ // separators: / ? #
369
+ t[SLASH] = 1;
370
+ t[QUESTION] = 1;
371
+ t[HASH] = 1;
372
+ // percent-encoding: %
373
+ t[PERCENT] = 1;
374
+ return t;
375
+ })();
376
+ /** @nodocs */
377
+ export const is_valid_path_char = (char_code) => char_code < 128 && PATH_CHAR_TABLE[char_code] === 1;
378
+ /**
379
+ * Trim trailing punctuation from URL/path per RFC 3986 and GFM rules.
380
+ * - Trims simple trailing: .,;:!?]
381
+ * - Balanced logic for () only (valid in path components)
382
+ *
383
+ * Note on `]`: the mdz parsers (`mdz.ts`, `mdz_stream_parser_url.ts`,
384
+ * `mdz_lexer.ts`) scan URL/path chars through `is_valid_path_char`, which
385
+ * already rejects `]` — so it can never reach this function via parser flow.
386
+ * The `]` branch here is for external callers using this helper directly on
387
+ * arbitrary URL-ish input as part of the published `@fuzdev/mdz` surface.
388
+ *
389
+ * Optimized to avoid O(n²) string slicing - tracks end index and slices once at the end.
390
+ */
391
+ export const mdz_trim_url_punctuation = (url) => {
392
+ let end = url.length;
393
+ while (end > 0) {
394
+ const last_char = url.charCodeAt(end - 1);
395
+ if (last_char === PERIOD ||
396
+ last_char === COMMA ||
397
+ last_char === SEMICOLON ||
398
+ last_char === COLON ||
399
+ last_char === EXCLAMATION ||
400
+ last_char === QUESTION ||
401
+ last_char === RIGHT_BRACKET) {
402
+ end--;
403
+ }
404
+ else {
405
+ break;
406
+ }
407
+ }
408
+ // Handle balanced parentheses ONLY (parens are valid in URI path components)
409
+ // Only count parens when the trimmed URL ends with ')' — otherwise the
410
+ // trailing-paren loop below can't trim anything.
411
+ if (end > 0 && url.charCodeAt(end - 1) === RIGHT_PAREN) {
412
+ let open_count = 0;
413
+ let close_count = 0;
414
+ for (let i = 0; i < end; i++) {
415
+ const char = url.charCodeAt(i);
416
+ if (char === LEFT_PAREN)
417
+ open_count++;
418
+ if (char === RIGHT_PAREN)
419
+ close_count++;
420
+ }
421
+ // Trim unmatched trailing closing parens
422
+ while (end > 0 && close_count > open_count) {
423
+ const last_char = url.charCodeAt(end - 1);
424
+ if (last_char === RIGHT_PAREN) {
425
+ end--;
426
+ close_count--;
427
+ }
428
+ else {
429
+ break;
430
+ }
431
+ }
432
+ }
433
+ // Return original string if no trimming, otherwise slice once
434
+ return end === url.length ? url : url.slice(0, end);
435
+ };
436
+ /**
437
+ * Check if position in text is the start of an absolute path (starts with `/`).
438
+ * Must be preceded by whitespace or be at the start of the string.
439
+ * Rejects `//` (comments/protocol-relative) and slash followed by whitespace.
440
+ *
441
+ * @nodocs
442
+ */
443
+ export const is_at_absolute_path = (text, index) => {
444
+ if (text.charCodeAt(index) !== SLASH)
445
+ return false;
446
+ if (index > 0) {
447
+ const prev_char = text.charCodeAt(index - 1);
448
+ if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB)
449
+ return false;
450
+ }
451
+ if (index + 1 >= text.length)
452
+ return false;
453
+ const next_char = text.charCodeAt(index + 1);
454
+ return next_char !== SLASH && next_char !== SPACE && next_char !== NEWLINE && next_char !== TAB;
455
+ };
456
+ /**
457
+ * Check if position in text is the start of a relative path (`./` or `../`).
458
+ * Must be preceded by whitespace or be at the start of the string.
459
+ * Rejects prefix followed by whitespace, slash, or end of string.
460
+ *
461
+ * @nodocs
462
+ */
463
+ export const is_at_relative_path = (text, index) => {
464
+ if (text.charCodeAt(index) !== PERIOD)
465
+ return false;
466
+ if (index > 0) {
467
+ const prev_char = text.charCodeAt(index - 1);
468
+ if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB)
469
+ return false;
470
+ }
471
+ const remaining = text.length - index;
472
+ // Check for ../ (at least 4 chars: ../x)
473
+ if (remaining >= 4 &&
474
+ text.charCodeAt(index + 1) === PERIOD &&
475
+ text.charCodeAt(index + 2) === SLASH) {
476
+ const after = text.charCodeAt(index + 3);
477
+ return after !== SPACE && after !== NEWLINE && after !== TAB && after !== SLASH;
478
+ }
479
+ // Check for ./ (at least 3 chars: ./x)
480
+ if (remaining >= 3 && text.charCodeAt(index + 1) === SLASH) {
481
+ const after = text.charCodeAt(index + 2);
482
+ return after !== SPACE && after !== NEWLINE && after !== TAB && after !== SLASH;
483
+ }
484
+ return false;
485
+ };
486
+ /**
487
+ * Extracts plain text content from an array of mdz nodes, recursing into children.
488
+ */
489
+ export const mdz_text_content = (nodes) => nodes
490
+ .map((n) => ('children' in n ? mdz_text_content(n.children) : 'content' in n ? n.content : ''))
491
+ .join('');
492
+ // lowercase slug for heading ids: lowercase, strip chars outside word/space/hyphen,
493
+ // then collapse whitespace and hyphen runs into a single `-` (empty segments dropped)
494
+ const slugify = (str) => str
495
+ .toLowerCase()
496
+ .replace(/[^\s\w-]/g, '')
497
+ .split(/[\s-]+/g)
498
+ .filter(Boolean)
499
+ .join('-');
500
+ /**
501
+ * Generates a lowercase slug id for a heading from its child nodes.
502
+ * Follows standard markdown conventions (GitHub, etc.) where heading IDs are lowercased.
503
+ */
504
+ export const mdz_heading_id = (nodes) => slugify(mdz_text_content(nodes));
505
+ /**
506
+ * Generates a lowercase slug id for a heading from plain text content.
507
+ * Used by the streaming parser which tracks text content directly
508
+ * rather than building `MdzNode[]` trees.
509
+ */
510
+ export const mdz_heading_id_from_text = (text) => slugify(text);
511
+ /**
512
+ * Check if a string is a URL (`https://` or `http://`).
513
+ * Requires at least one valid character after the protocol.
514
+ * Rejects whitespace and characters that can't start a valid hostname.
515
+ */
516
+ const URL_PATTERN = /^https?:\/\/[^\s)\]}<>.,:/?#!]/i;
517
+ export const mdz_is_url = (s) => URL_PATTERN.test(s);
518
+ /**
519
+ * Check if a link reference is safe to use as an `href` attribute.
520
+ * References without a colon are always safe (paths, fragments, queries).
521
+ * References with a colon must use `http(s)://` — rejects `javascript:`, `data:`, etc.
522
+ */
523
+ const SAFE_PROTOCOL_PATTERN = /^https?:\/\//i;
524
+ export const mdz_is_safe_reference = (reference) => !reference.includes(':') || SAFE_PROTOCOL_PATTERN.test(reference);
525
+ const VOID_ELEMENTS = new Set([
526
+ 'area',
527
+ 'base',
528
+ 'br',
529
+ 'col',
530
+ 'embed',
531
+ 'hr',
532
+ 'img',
533
+ 'input',
534
+ 'link',
535
+ 'meta',
536
+ 'source',
537
+ 'track',
538
+ 'wbr',
539
+ ]);
540
+ /**
541
+ * Check if an element name is an HTML void element (`br`, `img`, `hr`, ...).
542
+ * Void elements render self-closing; any parsed children are dropped.
543
+ */
544
+ // TODO the parsers don't know void-ness — `<br>text</br>` parses as an open/close
545
+ // pair with children that renderers silently drop; consider treating void tags as
546
+ // implicitly self-closing at parse time, or rejecting the open/close form
547
+ export const mdz_is_void_element = (name) => VOID_ELEMENTS.has(name);
548
+ /**
549
+ * Resolves a relative path (`./` or `../`) against a base path.
550
+ * The base is treated as a directory regardless of trailing slash
551
+ * (`'/docs/usage'` and `'/docs/usage/'` behave identically).
552
+ * Handles embedded `.` and `..` segments within the reference
553
+ * (e.g., `'./a/../b'` → navigates up then down).
554
+ * Clamps at root — excess `..` segments stop at `/` rather than escaping.
555
+ *
556
+ * @param reference - a relative path starting with `./` or `../`
557
+ * @param base - An absolute base path (e.g., `'/docs/usage/'`). Empty string is treated as root.
558
+ * @returns an absolute resolved path (e.g., `'/docs/usage/grammar'`)
559
+ */
560
+ export const mdz_resolve_relative_path = (reference, base) => {
561
+ const segments = base.split('/');
562
+ // Remove trailing empty from split (e.g., '/docs/usage/' → ['', 'docs', 'mdz', ''])
563
+ // but keep the root segment ([''] from '' base or ['', ''] from '/').
564
+ if (segments.length > 1 && segments.at(-1) === '')
565
+ segments.pop();
566
+ const trailing = reference.endsWith('/');
567
+ for (const segment of reference.split('/')) {
568
+ if (segment === '.' || segment === '')
569
+ continue;
570
+ if (segment === '..') {
571
+ if (segments.length > 1)
572
+ segments.pop(); // clamp at root
573
+ }
574
+ else {
575
+ segments.push(segment);
576
+ }
577
+ }
578
+ if (trailing)
579
+ segments.push('');
580
+ return segments.join('/');
581
+ };
582
+ /**
583
+ * Push a node into a children array, coalescing with the previous Text node.
584
+ * Mutates `prev.content` and `prev.end` when both are Text, avoiding array growth
585
+ * and an extra allocation. Callers must own `dest` and not retain references to
586
+ * the prior last element across the call.
587
+ */
588
+ export const mdz_push_merging_text = (dest, node) => {
589
+ if (node.type === 'Text') {
590
+ const last = dest[dest.length - 1];
591
+ if (last?.type === 'Text') {
592
+ last.content += node.content;
593
+ last.end = node.end;
594
+ return;
595
+ }
596
+ }
597
+ dest.push(node);
598
+ };
599
+ /**
600
+ * Return a new array with adjacent Text nodes merged into single nodes.
601
+ * Fast path: returns the original array when no merging is needed.
602
+ */
603
+ export const mdz_merge_adjacent_text = (nodes) => {
604
+ if (nodes.length <= 1)
605
+ return nodes;
606
+ let needs_merge = false;
607
+ for (let i = 1; i < nodes.length; i++) {
608
+ if (nodes[i - 1].type === 'Text' && nodes[i].type === 'Text') {
609
+ needs_merge = true;
610
+ break;
611
+ }
612
+ }
613
+ if (!needs_merge)
614
+ return nodes;
615
+ const merged = [];
616
+ let pending = null;
617
+ for (const node of nodes) {
618
+ if (node.type === 'Text') {
619
+ if (pending) {
620
+ pending = {
621
+ type: 'Text',
622
+ content: pending.content + node.content,
623
+ start: pending.start,
624
+ end: node.end,
625
+ };
626
+ }
627
+ else {
628
+ pending = { ...node };
629
+ }
630
+ }
631
+ else {
632
+ if (pending) {
633
+ merged.push(pending);
634
+ pending = null;
635
+ }
636
+ merged.push(node);
637
+ }
638
+ }
639
+ if (pending)
640
+ merged.push(pending);
641
+ return merged;
642
+ };
643
+ /**
644
+ * Extracts a single tag (component or element) if it's the only non-whitespace content.
645
+ *
646
+ * @returns the tag node if paragraph wrapping should be skipped (MDX convention),
647
+ * or `null` if the content should be wrapped in a paragraph
648
+ */
649
+ export const mdz_extract_single_tag = (nodes) => {
650
+ let tag = null;
651
+ for (const node of nodes) {
652
+ if (node.type === 'Component' || node.type === 'Element') {
653
+ if (tag)
654
+ return null; // Multiple tags
655
+ tag = node;
656
+ }
657
+ else if (node.type === 'Text') {
658
+ // Allow only whitespace-only text nodes
659
+ if (node.content.trim() !== '')
660
+ return null;
661
+ }
662
+ else {
663
+ // Any other node type means not a single tag
664
+ return null;
665
+ }
666
+ }
667
+ return tag;
668
+ };