@fuzdev/mdz 0.8.1 → 0.10.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 (64) hide show
  1. package/dist/Mdz.svelte +37 -1
  2. package/dist/Mdz.svelte.d.ts +35 -0
  3. package/dist/Mdz.svelte.d.ts.map +1 -1
  4. package/dist/MdzNodeView.svelte +143 -27
  5. package/dist/MdzNodeView.svelte.d.ts +13 -1
  6. package/dist/MdzNodeView.svelte.d.ts.map +1 -1
  7. package/dist/MdzPrecompiled.svelte.d.ts.map +1 -1
  8. package/dist/MdzRoot.svelte.d.ts.map +1 -1
  9. package/dist/MdzStream.svelte.d.ts.map +1 -1
  10. package/dist/MdzStreamNodeView.svelte.d.ts.map +1 -1
  11. package/dist/context_helpers.d.ts.map +1 -1
  12. package/dist/mdz.d.ts +4 -1
  13. package/dist/mdz.d.ts.map +1 -1
  14. package/dist/mdz.js +4 -1
  15. package/dist/mdz_contexts.d.ts +14 -2
  16. package/dist/mdz_contexts.d.ts.map +1 -1
  17. package/dist/mdz_debug_work.d.ts.map +1 -1
  18. package/dist/mdz_helpers.d.ts +57 -7
  19. package/dist/mdz_helpers.d.ts.map +1 -1
  20. package/dist/mdz_helpers.js +113 -50
  21. package/dist/mdz_lexer.d.ts.map +1 -1
  22. package/dist/mdz_lexer.js +21 -17
  23. package/dist/mdz_opcodes.d.ts.map +1 -1
  24. package/dist/mdz_opcodes_to_nodes.d.ts.map +1 -1
  25. package/dist/mdz_stream_parser.d.ts +16 -3
  26. package/dist/mdz_stream_parser.d.ts.map +1 -1
  27. package/dist/mdz_stream_parser.js +16 -3
  28. package/dist/mdz_stream_parser_block.d.ts +2 -2
  29. package/dist/mdz_stream_parser_block.d.ts.map +1 -1
  30. package/dist/mdz_stream_parser_block.js +2 -2
  31. package/dist/mdz_stream_parser_inline.d.ts.map +1 -1
  32. package/dist/mdz_stream_parser_inline.js +4 -4
  33. package/dist/mdz_stream_parser_link.d.ts.map +1 -1
  34. package/dist/mdz_stream_parser_list.d.ts.map +1 -1
  35. package/dist/mdz_stream_parser_state.d.ts +32 -12
  36. package/dist/mdz_stream_parser_state.d.ts.map +1 -1
  37. package/dist/mdz_stream_parser_state.js +21 -14
  38. package/dist/mdz_stream_parser_table.d.ts.map +1 -1
  39. package/dist/mdz_stream_parser_text.d.ts.map +1 -1
  40. package/dist/mdz_stream_parser_text.js +7 -4
  41. package/dist/mdz_stream_parser_url.d.ts +3 -2
  42. package/dist/mdz_stream_parser_url.d.ts.map +1 -1
  43. package/dist/mdz_stream_parser_url.js +114 -42
  44. package/dist/mdz_stream_state.svelte.d.ts.map +1 -1
  45. package/dist/mdz_to_svelte.d.ts.map +1 -1
  46. package/dist/mdz_token_parser.d.ts.map +1 -1
  47. package/dist/svelte_preprocess_mdz.d.ts.map +1 -1
  48. package/dist/svelte_preprocess_mdz.js +8 -0
  49. package/dist/tsdoc_mdz.d.ts +8 -4
  50. package/dist/tsdoc_mdz.d.ts.map +1 -1
  51. package/dist/tsdoc_mdz.js +14 -4
  52. package/package.json +7 -7
  53. package/src/lib/mdz.ts +4 -1
  54. package/src/lib/mdz_contexts.ts +20 -2
  55. package/src/lib/mdz_helpers.ts +117 -49
  56. package/src/lib/mdz_lexer.ts +31 -14
  57. package/src/lib/mdz_stream_parser.ts +16 -3
  58. package/src/lib/mdz_stream_parser_block.ts +2 -2
  59. package/src/lib/mdz_stream_parser_inline.ts +4 -4
  60. package/src/lib/mdz_stream_parser_state.ts +34 -13
  61. package/src/lib/mdz_stream_parser_text.ts +13 -6
  62. package/src/lib/mdz_stream_parser_url.ts +130 -49
  63. package/src/lib/svelte_preprocess_mdz.ts +8 -0
  64. package/src/lib/tsdoc_mdz.ts +15 -4
@@ -528,6 +528,18 @@ export const is_word_char = (char_code: number): boolean =>
528
528
  (char_code >= A_LOWER && char_code <= Z_LOWER) ||
529
529
  (char_code >= ZERO && char_code <= NINE);
530
530
 
531
+ /**
532
+ * Whether a char next to an italic `_` blocks it from opening (the char
533
+ * before) or closing (the char after): a word char, or `_` itself — italic is
534
+ * self-opaque, so runs like `__init__` never pair. The one italic boundary
535
+ * predicate, shared by the lexer, the streaming parser, and the streaming
536
+ * auto-link span resolution.
537
+ *
538
+ * @nodocs
539
+ */
540
+ export const is_italic_boundary_blocker = (char_code: number): boolean =>
541
+ is_word_char(char_code) || char_code === UNDERSCORE;
542
+
531
543
  /**
532
544
  * Lookup table for valid URI path characters per RFC 3986.
533
545
  * Replaces a 16-comparison chain with a single array access.
@@ -587,7 +599,7 @@ export const TEXT_CLASS_STOP = 1;
587
599
  export const TEXT_CLASS_NEWLINE = 2;
588
600
  /** `h`/`H` — potential URL scheme start, needs the prefix probe. @nodocs */
589
601
  export const TEXT_CLASS_URL = 4;
590
- /** `/` and `.` — potential path start, needs the boundary probe. @nodocs */
602
+ /** `/` and `.` — potential path start, needs the `is_path_boundary` probe. @nodocs */
591
603
  export const TEXT_CLASS_PATH = 8;
592
604
  /** `\` — a literal-pipe escape candidate inside table cells only. @nodocs */
593
605
  export const TEXT_CLASS_BACKSLASH = 16;
@@ -611,10 +623,23 @@ export const TEXT_SCAN_CLASS: Uint8Array = (() => {
611
623
  return t;
612
624
  })();
613
625
 
626
+ // trailing chars that never end a URL/path reference
627
+ const is_trailing_punctuation = (char_code: number): boolean =>
628
+ char_code === PERIOD ||
629
+ char_code === COMMA ||
630
+ char_code === SEMICOLON ||
631
+ char_code === COLON ||
632
+ char_code === EXCLAMATION ||
633
+ char_code === QUESTION ||
634
+ char_code === RIGHT_BRACKET;
635
+
614
636
  /**
615
- * Trim trailing punctuation from URL/path per RFC 3986 and GFM rules.
616
- * - Trims simple trailing: .,;:!?]
617
- * - Balanced logic for () only (valid in path components)
637
+ * Trim the trailing characters a URL/path scan consumed that read as
638
+ * surrounding prose rather than the reference, per GFM's autolink
639
+ * conventions, repeated until nothing more trims:
640
+ * - trailing punctuation `.,;:!?]`
641
+ * - an unbalanced trailing `)` (parens are valid in path components, so a
642
+ * balanced `(…)` stays)
618
643
  *
619
644
  * Note on `]`: the mdz parsers (`mdz.ts`, `mdz_stream_parser_url.ts`,
620
645
  * `mdz_lexer.ts`) scan URL/path chars through `is_valid_path_char`, which
@@ -622,68 +647,111 @@ export const TEXT_SCAN_CLASS: Uint8Array = (() => {
622
647
  * The `]` branch here is for external callers using this helper directly on
623
648
  * arbitrary URL-ish input as part of the published `@fuzdev/mdz` surface.
624
649
  *
625
- * Optimized to avoid O(n²) string slicing - tracks end index and slices once at the end.
650
+ * Tracks the end index and slices once, avoiding O(n²) string slicing.
626
651
  */
627
652
  export const mdz_trim_url_punctuation = (url: string): string => {
628
653
  let end = url.length;
654
+ // paren counts over the untrimmed prefix — counted once, on the first
655
+ // trailing `)`; the chars trimmed after that hold no parens except the
656
+ // trimmed `)` themselves, which decrement `close_count`
657
+ let open_count = -1;
658
+ let close_count = 0;
629
659
 
630
- while (end > 0) {
631
- const last_char = url.charCodeAt(end - 1);
632
- if (
633
- last_char === PERIOD ||
634
- last_char === COMMA ||
635
- last_char === SEMICOLON ||
636
- last_char === COLON ||
637
- last_char === EXCLAMATION ||
638
- last_char === QUESTION ||
639
- last_char === RIGHT_BRACKET
640
- ) {
641
- end--;
642
- } else {
643
- break;
660
+ for (;;) {
661
+ while (end > 0 && is_trailing_punctuation(url.charCodeAt(end - 1))) end--;
662
+ if (end === 0 || url.charCodeAt(end - 1) !== RIGHT_PAREN) break;
663
+ if (open_count === -1) {
664
+ open_count = 0;
665
+ for (let i = 0; i < end; i++) {
666
+ const char = url.charCodeAt(i);
667
+ if (char === LEFT_PAREN) open_count++;
668
+ else if (char === RIGHT_PAREN) close_count++;
669
+ }
644
670
  }
671
+ if (close_count <= open_count) break; // balanced — the `)` belongs to the reference
672
+ end--;
673
+ close_count--;
645
674
  }
646
675
 
647
- // Handle balanced parentheses ONLY (parens are valid in URI path components)
648
- // Only count parens when the trimmed URL ends with ')' — otherwise the
649
- // trailing-paren loop below can't trim anything.
650
- if (end > 0 && url.charCodeAt(end - 1) === RIGHT_PAREN) {
651
- let open_count = 0;
652
- let close_count = 0;
653
- for (let i = 0; i < end; i++) {
654
- const char = url.charCodeAt(i);
655
- if (char === LEFT_PAREN) open_count++;
656
- if (char === RIGHT_PAREN) close_count++;
657
- }
676
+ return end === url.length ? url : url.slice(0, end);
677
+ };
658
678
 
659
- // Trim unmatched trailing closing parens
660
- while (end > 0 && close_count > open_count) {
661
- const last_char = url.charCodeAt(end - 1);
662
- if (last_char === RIGHT_PAREN) {
663
- end--;
664
- close_count--;
665
- } else {
666
- break;
679
+ /**
680
+ * End of the URI-char run starting at `start` — where an auto-link scan
681
+ * stops, before any trailing trim. A path opened by `(` (`paren_opened`,
682
+ * see `is_path_boundary`) also ends before the `)` that matches that opener:
683
+ * a `(` inside the path raises the depth, so `(./Foo_(bar))` scans
684
+ * `./Foo_(bar)`.
685
+ *
686
+ * @nodocs
687
+ */
688
+ export const scan_uri_chars_end = (text: string, start: number, paren_opened: boolean): number => {
689
+ let i = start;
690
+ let depth = 0;
691
+ while (i < text.length) {
692
+ const c = text.charCodeAt(i);
693
+ if (!is_valid_path_char(c)) break;
694
+ if (paren_opened) {
695
+ if (c === LEFT_PAREN) depth++;
696
+ else if (c === RIGHT_PAREN) {
697
+ if (depth === 0) break;
698
+ depth--;
667
699
  }
668
700
  }
701
+ i++;
669
702
  }
670
-
671
- // Return original string if no trimming, otherwise slice once
672
- return end === url.length ? url : url.slice(0, end);
703
+ return i;
673
704
  };
674
705
 
706
+ /**
707
+ * Whether an internal path reference is only its prefix (`/`, `./`, `../`)
708
+ * with nothing after it — such a reference never links, since a path is its
709
+ * prefix plus at least one URI char.
710
+ *
711
+ * @nodocs
712
+ */
713
+ export const is_bare_path_prefix = (reference: string): boolean =>
714
+ reference === '/' || reference === './' || reference === '../';
715
+
716
+ // the char code `distance` chars before `index`, or `-1` past the start of input
717
+ const char_code_before = (text: string, index: number, distance: number): number =>
718
+ index >= distance ? text.charCodeAt(index - distance) : -1;
719
+
720
+ /**
721
+ * Whether an auto-detected path (`/…`, `./…`, `../…`) may start after the
722
+ * given characters: at the start of input, after a space, tab, or newline, or
723
+ * after an opening `(` that is itself neither glued to a word char nor the
724
+ * `](` of markdown link syntax. The paren case covers parenthesized
725
+ * references like `(./foo.md)` and `(see /docs)` — a path opened by `(` ends
726
+ * at its matching `)` (`scan_uri_chars_end`), and an unbalanced `)` after a
727
+ * whitespace-opened path is trimmed by `mdz_trim_url_punctuation` — while
728
+ * call-shaped `foo(./bar)` and a failed link's `[text](./foo bar)` stay
729
+ * literal. Every other preceding character (word chars, `[`, `{`, quotes,
730
+ * `*`, `_`, `~`, `/`, `.`) keeps a following path literal.
731
+ *
732
+ * @param prev - the char code directly before the path, or `-1` at the start of input
733
+ * @param before_prev - the char code before `prev`, or `-1` at the start of input
734
+ * @nodocs
735
+ */
736
+ export const is_path_boundary = (prev: number, before_prev: number): boolean =>
737
+ prev === -1 ||
738
+ prev === SPACE ||
739
+ prev === NEWLINE ||
740
+ prev === TAB ||
741
+ (prev === LEFT_PAREN && before_prev !== RIGHT_BRACKET && !is_word_char(before_prev));
742
+
675
743
  /**
676
744
  * Check if position in text is the start of an absolute path (starts with `/`).
677
- * Must be preceded by whitespace or be at the start of the string.
745
+ * Must sit at a path boundary (see `is_path_boundary`): start of input,
746
+ * whitespace, or a `(` not glued to a word or a `]`.
678
747
  * Rejects `//` (comments/protocol-relative) and slash followed by whitespace.
679
748
  *
680
749
  * @nodocs
681
750
  */
682
751
  export const is_at_absolute_path = (text: string, index: number): boolean => {
683
752
  if (text.charCodeAt(index) !== SLASH) return false;
684
- if (index > 0) {
685
- const prev_char = text.charCodeAt(index - 1);
686
- if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) return false;
753
+ if (!is_path_boundary(char_code_before(text, index, 1), char_code_before(text, index, 2))) {
754
+ return false;
687
755
  }
688
756
  if (index + 1 >= text.length) return false;
689
757
  const next_char = text.charCodeAt(index + 1);
@@ -692,16 +760,16 @@ export const is_at_absolute_path = (text: string, index: number): boolean => {
692
760
 
693
761
  /**
694
762
  * Check if position in text is the start of a relative path (`./` or `../`).
695
- * Must be preceded by whitespace or be at the start of the string.
763
+ * Must sit at a path boundary (see `is_path_boundary`): start of input,
764
+ * whitespace, or a `(` not glued to a word or a `]`.
696
765
  * Rejects prefix followed by whitespace, slash, or end of string.
697
766
  *
698
767
  * @nodocs
699
768
  */
700
769
  export const is_at_relative_path = (text: string, index: number): boolean => {
701
770
  if (text.charCodeAt(index) !== PERIOD) return false;
702
- if (index > 0) {
703
- const prev_char = text.charCodeAt(index - 1);
704
- if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) return false;
771
+ if (!is_path_boundary(char_code_before(text, index, 1), char_code_before(text, index, 2))) {
772
+ return false;
705
773
  }
706
774
  const remaining = text.length - index;
707
775
  // Check for ../ (at least 4 chars: ../x)
@@ -14,6 +14,9 @@ import {
14
14
  is_tag_name_char,
15
15
  is_word_char,
16
16
  is_valid_path_char,
17
+ is_bare_path_prefix,
18
+ is_italic_boundary_blocker,
19
+ scan_uri_chars_end,
17
20
  mdz_trim_url_punctuation,
18
21
  BACKTICK,
19
22
  ASTERISK,
@@ -2133,6 +2136,13 @@ export class MdzLexer {
2133
2136
  reference = mdz_trim_url_punctuation(reference);
2134
2137
  this.#index = start + reference.length;
2135
2138
 
2139
+ // a bare scheme (`https://`) left after trimming isn't a URL — literal text,
2140
+ // matching the streaming parser's completion check
2141
+ if (reference.length <= match_url_prefix_case_insensitive(reference, 0)) {
2142
+ this.#emit_text(reference, start);
2143
+ return;
2144
+ }
2145
+
2136
2146
  this.#tokens.push({
2137
2147
  type: 'autolink',
2138
2148
  reference,
@@ -2145,19 +2155,24 @@ export class MdzLexer {
2145
2155
  #tokenize_auto_link_internal(): void {
2146
2156
  const start = this.#index;
2147
2157
 
2148
- // Collect path characters
2149
- while (this.#index < this.#text.length) {
2150
- const char_code = this.#text.charCodeAt(this.#index);
2151
- if (char_code === SPACE || char_code === NEWLINE || !is_valid_path_char(char_code)) {
2152
- break;
2153
- }
2154
- this.#index++;
2155
- }
2158
+ // collect path characters — a path opened by `(` ends at its matching `)`
2159
+ // (the probes only admit a `(` before a path at a valid boundary)
2160
+ this.#index = scan_uri_chars_end(
2161
+ this.#text,
2162
+ start,
2163
+ start > 0 && this.#text.charCodeAt(start - 1) === LEFT_PAREN
2164
+ );
2156
2165
 
2157
2166
  let reference = this.#text.slice(start, this.#index);
2158
2167
  reference = mdz_trim_url_punctuation(reference);
2159
2168
  this.#index = start + reference.length;
2160
2169
 
2170
+ // a bare prefix (`./`, `/`) left after trimming isn't a path — literal text
2171
+ if (is_bare_path_prefix(reference)) {
2172
+ this.#emit_text(reference, start);
2173
+ return;
2174
+ }
2175
+
2161
2176
  this.#tokens.push({
2162
2177
  type: 'autolink',
2163
2178
  reference,
@@ -2240,13 +2255,15 @@ export class MdzLexer {
2240
2255
  * transparent (so `_~~x~~_` nests).
2241
2256
  */
2242
2257
  #is_at_word_boundary(index: number, check_before: boolean, check_after: boolean): boolean {
2243
- if (check_before && index > 0) {
2244
- const prev = this.#text.charCodeAt(index - 1);
2245
- if (is_word_char(prev) || prev === UNDERSCORE) return false;
2258
+ if (check_before && index > 0 && is_italic_boundary_blocker(this.#text.charCodeAt(index - 1))) {
2259
+ return false;
2246
2260
  }
2247
- if (check_after && index < this.#text.length) {
2248
- const next = this.#text.charCodeAt(index);
2249
- if (is_word_char(next) || next === UNDERSCORE) return false;
2261
+ if (
2262
+ check_after &&
2263
+ index < this.#text.length &&
2264
+ is_italic_boundary_blocker(this.#text.charCodeAt(index))
2265
+ ) {
2266
+ return false;
2250
2267
  }
2251
2268
  return true;
2252
2269
  }
@@ -7,7 +7,7 @@
7
7
  * corrections are bounded, explicit opcodes (`revert`, `wrap`, `trim_text`).
8
8
  *
9
9
  * The design is derived from
10
- * {@link https://bsky.app/profile/pngwn.at/post/3mi527zntb22n @pngwn.at}'s
10
+ * [@pngwn.at](https://bsky.app/profile/pngwn.at/post/3mi527zntb22n)'s
11
11
  * ideas: restrict the syntax so streaming is tractable, render
12
12
  * optimistically and correct when wrong, emit serializable opcodes to avoid
13
13
  * re-parsing, and keep the opcodes target-agnostic so any renderer can
@@ -138,9 +138,22 @@ import { consume_delimiter_as_text, consume_text_run } from './mdz_stream_parser
138
138
  * Italic is the only wedge (the only delimiter whose one-shot form rejects
139
139
  * on a failed first closer); see `try_code`'s hold and `code_search_limit`.
140
140
  * - **Optimistic inline code unclosed at EOF** — a `` ` `` that opened
141
- * optimistically consumes its tail as raw code text, so formatting inside it
142
- * never forms (`` h `x **b** z `` stays flat where one-shot parses the bold);
141
+ * optimistically consumes its tail as raw code text, so formatting and
142
+ * auto-links inside it never form (`x **b** z` and `(./a.md)` after an
143
+ * unclosed backtick stay flat where one-shot parses the bold and the link);
143
144
  * the parser never re-parses, so the EOF revert can only flatten it to text.
145
+ * - **A span crossing a markdown link's `]`** — the one-shot parse pairs a
146
+ * `**`/`_`/`~~` opener with the first occurrence of its delimiter even across
147
+ * a `[text](url)`'s bracket, because markdown links aren't bounded by an
148
+ * enclosing span. With the opener before the link and that occurrence in its
149
+ * display text, the link forms and the opener stays literal
150
+ * (`**x [a b** c](/u) y**`; also when the occurrence sits in an auto-link in
151
+ * the display text, `_x [a https://b_.c **d** e](/u) y_`); with the opener in
152
+ * the display text and the closer past `]`, a link that then fails re-lexes
153
+ * from `[` and the span forms (`[_]_`). Streaming decides the span before it
154
+ * knows whether the link completes, so it closes at the bracketed
155
+ * occurrence, or reverts the span with the failed link; mirroring the
156
+ * one-shot parse would need a hold to the link's `)` or a re-parse.
144
157
  * - **Link/tag opens at EOF** — `finish()` doesn't open links or tags, so a held
145
158
  * tail containing complete `[text](url)` / `<Tag>…</Tag>` syntax parses flat.
146
159
  * - **Block elements interrupt optimistic inlines** — at column 0 a
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Block-element handlers for the streaming mdz parser.
3
3
  *
4
- * Each `try_*` returns {@link TryResult} so the orchestrator in
5
- * {@link ./mdz_stream_parser.ts} can fall through or wait for more input.
4
+ * Each `try_*` returns `TryResult` so the orchestrator in
5
+ * `mdz_stream_parser.ts` can fall through or wait for more input.
6
6
  *
7
7
  * @module
8
8
  */
@@ -10,8 +10,8 @@ import {
10
10
  NEWLINE,
11
11
  RIGHT_ANGLE,
12
12
  UNDERSCORE,
13
+ is_italic_boundary_blocker,
13
14
  is_line_whitespace,
14
- is_word_char,
15
15
  mdz_blockquote_line_has_content,
16
16
  mdz_match_blockquote_prefix
17
17
  } from './mdz_helpers.ts';
@@ -190,7 +190,7 @@ export const try_double_delimiter = (
190
190
  export const try_italic = (state: MdzStreamParserState, forced = false): boolean => {
191
191
  // opening boundary: reject after a word char, or after the delimiter
192
192
  // itself — `_` is self-opaque, so runs like `__init__` stay literal
193
- if (state.prev_char !== -1 && (is_word_char(state.prev_char) || state.prev_char === UNDERSCORE)) {
193
+ if (state.prev_char !== -1 && is_italic_boundary_blocker(state.prev_char)) {
194
194
  consume_delimiter_as_text(state, '_');
195
195
  return true;
196
196
  }
@@ -248,7 +248,7 @@ export const check_close_word_boundary = (state: MdzStreamParserState): boolean
248
248
  const next_pos = state.pos + 1;
249
249
  if (next_pos < state.buffer.length) {
250
250
  const next = state.buffer.charCodeAt(next_pos);
251
- return !(is_word_char(next) || next === UNDERSCORE);
251
+ return !is_italic_boundary_blocker(next);
252
252
  }
253
253
  return true;
254
254
  };
@@ -327,7 +327,7 @@ export const scan_closer_boundary = (
327
327
  const after_pos = delim_pos + 1;
328
328
  if (after_pos < state.buffer.length) {
329
329
  const after = state.buffer.charCodeAt(after_pos);
330
- return is_word_char(after) || after === UNDERSCORE ? 'rejected' : 'confirmed';
330
+ return is_italic_boundary_blocker(after) ? 'rejected' : 'confirmed';
331
331
  }
332
332
  // closer at end of buffer — treat as confirmed, consistent with
333
333
  // check_close_word_boundary which also assumes boundary at buffer end
@@ -167,6 +167,12 @@ export interface PendingUrl {
167
167
  /** Prefix match tracking — only used when `!confirmed`. */
168
168
  viable_https: boolean;
169
169
  viable_http: boolean;
170
+ /**
171
+ * Paren depth inside a path opened by `(` — the path ends at a `)` at
172
+ * depth zero — or `-1` for every other URL/path, which scan to their
173
+ * terminator.
174
+ */
175
+ paren_depth: number;
170
176
  }
171
177
 
172
178
  /**
@@ -185,6 +191,14 @@ export interface MdzStreamParserState {
185
191
  stack: Array<StackEntry>;
186
192
  column: number;
187
193
  prev_char: number;
194
+ /**
195
+ * The char before `prev_char` — the second char of lookbehind the path
196
+ * boundary needs (`is_path_boundary`: a `(` opens a path only when not
197
+ * glued to a word or a `]`). Only `consume_text_run` maintains it, and it's read only
198
+ * when `prev_char` is `(`, which only a text run leaves behind ahead of a
199
+ * path-start char; elsewhere it may be stale.
200
+ */
201
+ before_prev_char: number;
188
202
  active_text_id: MdzNodeId | null;
189
203
  accumulated_text: string;
190
204
  accumulated_text_start: number;
@@ -331,6 +345,7 @@ export const create_state = (): MdzStreamParserState => ({
331
345
  stack: [],
332
346
  column: 0,
333
347
  prev_char: -1,
348
+ before_prev_char: -1,
334
349
  active_text_id: null,
335
350
  accumulated_text: '',
336
351
  accumulated_text_start: 0,
@@ -776,18 +791,24 @@ export const revert_above = (state: MdzStreamParserState, target_idx: number): v
776
791
  };
777
792
 
778
793
  /**
779
- * Revert an open `Italic` whose first closer candidate failed its boundary
780
- * check (italic is the only inline with closer-boundary rules). One-shot
781
- * parsing greedy-rejects the opener at the first invalid closer, so once a
782
- * failed candidate is seen, the optimistic open is known wrong — reverting
783
- * immediately (instead of leaving the container open for a later closer)
784
- * keeps chunked output identical to the one-shot parse.
794
+ * Revert one open frame, possibly **mid-stack**, without reverting the frames
795
+ * above it. Two callers:
796
+ *
797
+ * - an open `Italic` whose first closer candidate failed its boundary check
798
+ * (italic is the only inline with closer-boundary rules) — one-shot parsing
799
+ * greedy-rejects the opener at the first invalid closer, so once a failed
800
+ * candidate is seen the optimistic open is known wrong, and reverting
801
+ * immediately keeps chunked output identical to the one-shot parse
802
+ * - the auto-link span resolution (`revert_spans_closed_inside` in
803
+ * `mdz_stream_parser_url.ts`), which reverts `Bold`, `Italic`,
804
+ * `Strikethrough`, `Element`, and `Component` frames whose span the one-shot
805
+ * parse leaves literal, one frame at a time and possibly beneath a `Link`
806
+ * frame that stays open
785
807
  *
786
- * Unlike `revert_above`, this reverts a possibly
787
- * **mid-stack** entry: containers opened above it (e.g. a Bold inside the
788
- * failed Italic) stay open — the one-shot parse opens them at top level, and
789
- * the consumers' revert handlers re-parent them identically (the reverted
790
- * node is still its parent's last child; see `mdz_opcodes_to_nodes` and
808
+ * Containers opened above the reverted frame stay open, and the consumers'
809
+ * revert handlers re-parent them identically for every frame type — the
810
+ * reverted node is still its parent's last child, and both handlers work on
811
+ * node ids and children only (see `mdz_opcodes_to_nodes` and
791
812
  * `MdzStreamState`).
792
813
  *
793
814
  * Bypasses `emit` for the opcode push: `emit`'s heading-segment handling
@@ -813,8 +834,8 @@ export const revert_failed_close = (state: MdzStreamParserState, stack_idx: numb
813
834
  if (remaining === 0) counts.delete(entry.tag_name);
814
835
  else counts.set(entry.tag_name, remaining);
815
836
  }
816
- // only ever reverts an Italic today, but keep the depth counter honest if a
817
- // Link/Element/Component is ever failed-closed mid-stack
837
+ // Element/Component frames count toward the link/tag nesting depth (Link
838
+ // frames are never reverted here today, but keep the counter honest)
818
839
  if (entry.node_type === 'Link' || entry.tag_name !== undefined) state.open_link_tag_depth--;
819
840
  state.opcodes.push({
820
841
  type: 'revert',
@@ -6,14 +6,12 @@
6
6
 
7
7
  import {
8
8
  HTTPS_PREFIX_LENGTH,
9
- NEWLINE,
10
- SPACE,
11
- TAB,
12
9
  TEXT_CLASS_NEWLINE,
13
10
  TEXT_CLASS_PATH,
14
11
  TEXT_CLASS_STOP,
15
12
  TEXT_CLASS_URL,
16
13
  TEXT_SCAN_CLASS,
14
+ is_path_boundary,
17
15
  is_word_char,
18
16
  match_url_prefix_case_insensitive
19
17
  } from './mdz_helpers.ts';
@@ -82,15 +80,24 @@ export const consume_text_run = (state: MdzStreamParserState): void => {
82
80
  break;
83
81
  }
84
82
  } else if ((char_class & TEXT_CLASS_PATH) !== 0) {
85
- // path (`/` or `.`): only break at a plausible path start
86
- const prev = state.buffer.charCodeAt(state.pos - 1);
87
- if (prev === SPACE || prev === NEWLINE || prev === TAB) break;
83
+ // path (`/` or `.`): only break at a plausible path start — the run
84
+ // start's lookbehind is the incoming `prev_char`
85
+ if (
86
+ is_path_boundary(
87
+ state.buffer.charCodeAt(state.pos - 1),
88
+ state.pos - 2 >= start ? state.buffer.charCodeAt(state.pos - 2) : state.prev_char
89
+ )
90
+ ) {
91
+ break;
92
+ }
88
93
  }
89
94
  // backslash (or a non-starting path char) — ordinary text
90
95
  state.pos++;
91
96
  }
92
97
  if (mdz_debug_work.enabled) mdz_debug_work.total += state.pos - start;
93
98
  accumulate_text(state, state.buffer.slice(start, state.pos), offset(state, start));
99
+ state.before_prev_char =
100
+ state.pos - 2 >= start ? state.buffer.charCodeAt(state.pos - 2) : state.prev_char;
94
101
  state.prev_char = state.buffer.charCodeAt(state.pos - 1);
95
102
  state.column += state.pos - start;
96
103
  };