@fuzdev/fuz_ui 0.190.0 → 0.191.1

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 (81) hide show
  1. package/dist/ContextmenuRootForSafariCompatibility.svelte +2 -2
  2. package/dist/MdzNodeView.svelte +3 -2
  3. package/dist/MdzNodeView.svelte.d.ts +1 -1
  4. package/dist/MdzNodeView.svelte.d.ts.map +1 -1
  5. package/dist/ProjectLinks.svelte +4 -2
  6. package/dist/ProjectLinks.svelte.d.ts.map +1 -1
  7. package/dist/analysis_context.d.ts +3 -3
  8. package/dist/analysis_context.js +3 -3
  9. package/dist/contextmenu_state.svelte.d.ts +7 -7
  10. package/dist/contextmenu_state.svelte.js +7 -7
  11. package/dist/docs_helpers.svelte.d.ts +7 -5
  12. package/dist/docs_helpers.svelte.d.ts.map +1 -1
  13. package/dist/docs_helpers.svelte.js +7 -5
  14. package/dist/intersect.svelte.d.ts +1 -1
  15. package/dist/intersect.svelte.js +1 -1
  16. package/dist/library_analysis.d.ts +6 -6
  17. package/dist/library_analysis.js +6 -6
  18. package/dist/library_gen.d.ts +4 -4
  19. package/dist/library_gen.js +4 -4
  20. package/dist/library_generate.d.ts +2 -2
  21. package/dist/library_helpers.d.ts +11 -11
  22. package/dist/library_helpers.js +11 -11
  23. package/dist/library_pipeline.d.ts +5 -5
  24. package/dist/library_pipeline.js +5 -5
  25. package/dist/logos.d.ts +7 -0
  26. package/dist/logos.d.ts.map +1 -1
  27. package/dist/logos.js +5 -0
  28. package/dist/mdz.d.ts +1 -14
  29. package/dist/mdz.d.ts.map +1 -1
  30. package/dist/mdz.js +64 -163
  31. package/dist/mdz_helpers.d.ts +26 -2
  32. package/dist/mdz_helpers.d.ts.map +1 -1
  33. package/dist/mdz_helpers.js +59 -5
  34. package/dist/mdz_lexer.d.ts.map +1 -1
  35. package/dist/mdz_lexer.js +18 -9
  36. package/dist/mdz_to_svelte.d.ts +5 -5
  37. package/dist/mdz_to_svelte.d.ts.map +1 -1
  38. package/dist/mdz_to_svelte.js +5 -5
  39. package/dist/mdz_token_parser.js +4 -3
  40. package/dist/module_helpers.d.ts +9 -9
  41. package/dist/module_helpers.js +9 -9
  42. package/dist/package_helpers.d.ts +20 -20
  43. package/dist/package_helpers.js +20 -20
  44. package/dist/storage.d.ts +4 -4
  45. package/dist/storage.js +4 -4
  46. package/dist/svelte_helpers.d.ts +13 -13
  47. package/dist/svelte_helpers.js +13 -13
  48. package/dist/svelte_preprocess_mdz.d.ts +2 -2
  49. package/dist/svelte_preprocess_mdz.js +2 -2
  50. package/dist/tome.d.ts +1 -1
  51. package/dist/tome.js +1 -1
  52. package/dist/ts_helpers.d.ts +24 -24
  53. package/dist/ts_helpers.js +24 -24
  54. package/dist/tsdoc_helpers.d.ts +6 -6
  55. package/dist/tsdoc_helpers.js +6 -6
  56. package/dist/tsdoc_mdz.js +1 -1
  57. package/package.json +8 -7
  58. package/src/lib/analysis_context.ts +3 -3
  59. package/src/lib/contextmenu_state.svelte.ts +7 -7
  60. package/src/lib/docs_helpers.svelte.ts +7 -5
  61. package/src/lib/intersect.svelte.ts +1 -1
  62. package/src/lib/library_analysis.ts +6 -6
  63. package/src/lib/library_gen.ts +4 -4
  64. package/src/lib/library_generate.ts +2 -2
  65. package/src/lib/library_helpers.ts +11 -11
  66. package/src/lib/library_pipeline.ts +5 -5
  67. package/src/lib/logos.ts +6 -0
  68. package/src/lib/mdz.ts +73 -177
  69. package/src/lib/mdz_helpers.ts +60 -5
  70. package/src/lib/mdz_lexer.ts +20 -9
  71. package/src/lib/mdz_to_svelte.ts +6 -5
  72. package/src/lib/mdz_token_parser.ts +4 -3
  73. package/src/lib/module_helpers.ts +9 -9
  74. package/src/lib/package_helpers.ts +20 -20
  75. package/src/lib/storage.ts +4 -4
  76. package/src/lib/svelte_helpers.ts +13 -13
  77. package/src/lib/svelte_preprocess_mdz.ts +2 -2
  78. package/src/lib/tome.ts +1 -1
  79. package/src/lib/ts_helpers.ts +24 -24
  80. package/src/lib/tsdoc_helpers.ts +6 -6
  81. package/src/lib/tsdoc_mdz.ts +1 -1
package/src/lib/mdz.ts CHANGED
@@ -62,6 +62,8 @@ import {
62
62
  is_at_absolute_path,
63
63
  is_at_relative_path,
64
64
  extract_single_tag,
65
+ mdz_heading_id,
66
+ mdz_is_url,
65
67
  } from './mdz_helpers.js';
66
68
 
67
69
  // TODO design incremental parsing or some system that preserves Svelte components across re-renders when possible
@@ -141,6 +143,7 @@ export interface MdzHrNode extends MdzBaseNode {
141
143
  export interface MdzHeadingNode extends MdzBaseNode {
142
144
  type: 'Heading';
143
145
  level: 1 | 2 | 3 | 4 | 5 | 6;
146
+ id: string; // slugified heading text for fragment links
144
147
  children: Array<MdzNode>; // inline formatting allowed
145
148
  }
146
149
 
@@ -490,9 +493,9 @@ export class MdzParser {
490
493
  * - Empty content (e.g., `__` or `~~`)
491
494
  * - Paragraph break interrupts before closing delimiter
492
495
  *
493
- * @param delimiter - The delimiter character (`_` for italic, `~` for strikethrough)
494
- * @param node_type - The node type to create ('Italic' or 'Strikethrough')
495
- * @returns Formatted node or text node if validation fails
496
+ * @param delimiter - the delimiter character (`_` for italic, `~` for strikethrough)
497
+ * @param node_type - the node type to create ('Italic' or 'Strikethrough')
498
+ * @returns formatted node or text node if validation fails
496
499
  */
497
500
  #parse_single_delimiter_formatting(
498
501
  delimiter: '_',
@@ -718,107 +721,41 @@ export class MdzParser {
718
721
  #parse_tag(): MdzElementNode | MdzComponentNode | MdzTextNode {
719
722
  const start = this.#index;
720
723
 
721
- // Save parent accumulation state to avoid polluting component children with parent's accumulated text
722
- const saved_state = this.#save_accumulation_state();
723
-
724
- // Clear accumulation state for parsing component children
725
- this.#accumulated_text = '';
726
- this.#nodes.length = 0;
727
-
728
- // Consume <
729
- if (!this.#match('<')) {
730
- // Restore parent state before returning
731
- this.#restore_accumulation_state(saved_state);
732
-
733
- const content = this.#template[this.#index]!;
734
- this.#index++;
735
- return {
736
- type: 'Text',
737
- content,
738
- start,
739
- end: this.#index,
740
- };
741
- }
742
- this.#index++;
743
-
744
- // Parse tag name
745
- const tag_name_start = this.#index;
746
- let tag_name_end = this.#index;
724
+ // Phase 1: Validate tag structure using a local scan index.
725
+ // Avoids save/restore of accumulation state on the fast path
726
+ // (most `<` characters are not valid tags).
727
+ let i = start + 1; // skip <
747
728
 
748
729
  // Tag name must start with a letter
749
- if (this.#index >= this.#template.length) {
750
- // Just a `<` at EOF - restore parent state
751
- this.#restore_accumulation_state(saved_state);
752
- return {
753
- type: 'Text',
754
- content: '<',
755
- start,
756
- end: this.#index,
757
- };
758
- }
759
-
760
- const first_char = this.#template.charCodeAt(this.#index);
761
- if (!is_letter(first_char)) {
762
- // Not a valid tag, treat as text - restore parent state
763
- this.#restore_accumulation_state(saved_state);
764
- return {
765
- type: 'Text',
766
- content: '<',
767
- start,
768
- end: start + 1,
769
- };
730
+ if (i >= this.#template.length || !is_letter(this.#template.charCodeAt(i))) {
731
+ this.#index = start + 1;
732
+ return this.#make_text_node('<', start);
770
733
  }
771
734
 
772
735
  // Collect tag name (letters, numbers, hyphens, underscores)
773
- while (this.#index < this.#template.length) {
774
- const char_code = this.#template.charCodeAt(this.#index);
775
- if (is_tag_name_char(char_code)) {
776
- this.#index++;
777
- } else {
778
- break;
779
- }
780
- }
781
-
782
- tag_name_end = this.#index;
783
- const tag_name = this.#template.slice(tag_name_start, tag_name_end);
784
-
785
- if (tag_name.length === 0) {
786
- // Empty tag name - restore parent state
787
- this.#restore_accumulation_state(saved_state);
788
- return {
789
- type: 'Text',
790
- content: '<',
791
- start,
792
- end: start + 1,
793
- };
736
+ while (i < this.#template.length && is_tag_name_char(this.#template.charCodeAt(i))) {
737
+ i++;
794
738
  }
795
739
 
796
- // Determine if this is a Component (uppercase) or Element (lowercase)
740
+ const tag_name = this.#template.slice(start + 1, i);
797
741
  const first_char_code = tag_name.charCodeAt(0);
798
- const is_component = first_char_code >= A_UPPER && first_char_code <= Z_UPPER;
799
- const node_type: 'Component' | 'Element' = is_component ? 'Component' : 'Element';
742
+ const node_type: 'Component' | 'Element' =
743
+ first_char_code >= A_UPPER && first_char_code <= Z_UPPER ? 'Component' : 'Element';
800
744
 
801
745
  // Skip whitespace after tag name (for future attribute support)
802
- while (
803
- this.#index < this.#template.length &&
804
- this.#template.charCodeAt(this.#index) === SPACE
805
- ) {
806
- this.#index++;
746
+ while (i < this.#template.length && this.#template.charCodeAt(i) === SPACE) {
747
+ i++;
807
748
  }
808
749
 
809
750
  // TODO: Parse attributes here
810
751
 
811
752
  // Check for self-closing />
812
753
  if (
813
- this.#index + 1 < this.#template.length &&
814
- this.#template.charCodeAt(this.#index) === SLASH &&
815
- this.#template.charCodeAt(this.#index + 1) === RIGHT_ANGLE
754
+ i + 1 < this.#template.length &&
755
+ this.#template.charCodeAt(i) === SLASH &&
756
+ this.#template.charCodeAt(i + 1) === RIGHT_ANGLE
816
757
  ) {
817
- this.#index += 2;
818
-
819
- // Restore parent state before returning
820
- this.#restore_accumulation_state(saved_state);
821
-
758
+ this.#index = i + 2;
822
759
  return {
823
760
  type: node_type,
824
761
  name: tag_name,
@@ -828,41 +765,44 @@ export class MdzParser {
828
765
  };
829
766
  }
830
767
 
831
- // Check for closing >
832
- if (
833
- this.#index >= this.#template.length ||
834
- this.#template.charCodeAt(this.#index) !== RIGHT_ANGLE
835
- ) {
836
- // Unclosed opening tag, treat as text - restore parent state
837
- this.#restore_accumulation_state(saved_state);
838
-
768
+ // Must have closing >
769
+ if (i >= this.#template.length || this.#template.charCodeAt(i) !== RIGHT_ANGLE) {
839
770
  this.#index = start + 1;
840
- return {
841
- type: 'Text',
842
- content: '<',
843
- start,
844
- end: this.#index,
845
- };
771
+ return this.#make_text_node('<', start);
846
772
  }
847
773
 
848
- // Consume >
849
- this.#index++;
774
+ const content_start = i + 1; // past >
850
775
 
851
- // Parse children until closing tag
776
+ // Bail early if closing tag is missing, past a paragraph break,
777
+ // or past the search boundary (e.g. heading line end)
852
778
  const closing_tag = `</${tag_name}>`;
779
+ const search_limit = Math.min(this.#max_search_index, this.#template.length);
780
+ const close_tag_index = this.#template.indexOf(closing_tag, content_start);
781
+ if (close_tag_index === -1 || close_tag_index >= search_limit) {
782
+ this.#index = start + 1;
783
+ return this.#make_text_node('<', start);
784
+ }
785
+ const para_break = this.#template.indexOf('\n\n', content_start);
786
+ if (para_break !== -1 && para_break < close_tag_index) {
787
+ this.#index = start + 1;
788
+ return this.#make_text_node('<', start);
789
+ }
790
+
791
+ // Phase 2: Tag validated — save accumulation state and parse children.
792
+ const saved_state = this.#save_accumulation_state();
793
+ this.#accumulated_text = '';
794
+ this.#nodes.length = 0;
795
+ this.#index = content_start;
796
+
853
797
  const children: Array<MdzNode> = [];
854
798
 
855
799
  while (this.#index < this.#template.length) {
856
- // Check for closing tag
857
800
  if (this.#match(closing_tag)) {
858
- // Flush any accumulated text from children
859
801
  this.#flush_text();
860
802
  children.push(...this.#nodes);
861
803
  this.#nodes.length = 0;
862
804
 
863
805
  this.#index += closing_tag.length;
864
-
865
- // Restore parent state before returning
866
806
  this.#restore_accumulation_state(saved_state);
867
807
 
868
808
  return {
@@ -885,17 +825,10 @@ export class MdzParser {
885
825
  }
886
826
  }
887
827
 
888
- // Unclosed tag - reached EOF without finding closing tag
889
- // Treat the opening tag as text - restore parent state
828
+ // Defensive: pre-check guarantees closing tag exists, but handle EOF gracefully
890
829
  this.#restore_accumulation_state(saved_state);
891
-
892
830
  this.#index = start + 1;
893
- return {
894
- type: 'Text',
895
- content: '<',
896
- start,
897
- end: this.#index,
898
- };
831
+ return this.#make_text_node('<', start);
899
832
  }
900
833
 
901
834
  /**
@@ -930,7 +863,7 @@ export class MdzParser {
930
863
  /**
931
864
  * Restore previously saved text accumulation state.
932
865
  * Used to restore parent state when exiting nested structure parsing.
933
- * @param state - State object returned from `#save_accumulation_state()`
866
+ * @param state - state object returned from `#save_accumulation_state()`
934
867
  */
935
868
  #restore_accumulation_state(state: {
936
869
  accumulated_text: string;
@@ -948,9 +881,9 @@ export class MdzParser {
948
881
  * Word boundary = not surrounded by word characters (A-Z, a-z, 0-9).
949
882
  * Used to prevent intraword emphasis for underscores and tildes.
950
883
  *
951
- * @param index - Position to check
952
- * @param check_before - Whether to check the character before this position
953
- * @param check_after - Whether to check the character after this position
884
+ * @param index - position to check
885
+ * @param check_before - whether to check the character before this position
886
+ * @param check_after - whether to check the character after this position
954
887
  */
955
888
  #is_at_word_boundary(index: number, check_before: boolean, check_after: boolean): boolean {
956
889
  if (check_before && index > 0) {
@@ -1173,9 +1106,9 @@ export class MdzParser {
1173
1106
  * - Paragraph break (double newline) is encountered (allows block elements to interrupt inline formatting)
1174
1107
  * - `end_index` boundary is reached
1175
1108
  *
1176
- * @param delimiter - The delimiter string to stop at (e.g., '**', '_', ']')
1177
- * @param end_index - Optional maximum index to parse up to (for greedy/bounded parsing)
1178
- * @returns Array of parsed nodes (may be empty if delimiter found immediately)
1109
+ * @param delimiter - the delimiter string to stop at (e.g., '**', '_', ']')
1110
+ * @param end_index - optional maximum index to parse up to (for greedy/bounded parsing)
1111
+ * @returns array of parsed nodes (may be empty if delimiter found immediately)
1179
1112
  */
1180
1113
  #parse_nodes_until(delimiter: string, end_index?: number): Array<MdzNode> {
1181
1114
  const nodes: Array<MdzNode> = [];
@@ -1354,31 +1287,29 @@ export class MdzParser {
1354
1287
  // Consume the space after hashes (already verified to exist)
1355
1288
  this.#index++;
1356
1289
 
1357
- // Parse inline content until newline
1358
- const content_nodes: Array<MdzNode> = [];
1290
+ // Find end-of-line to bound nested parsers (prevents tag scanner from scanning past heading)
1291
+ let eol = this.#template.indexOf('\n', this.#index);
1292
+ if (eol === -1) eol = this.#template.length;
1359
1293
 
1360
- while (this.#index < this.#template.length) {
1361
- const char_code = this.#template.charCodeAt(this.#index);
1294
+ const saved_max_search_index = this.#max_search_index;
1295
+ this.#max_search_index = eol;
1362
1296
 
1363
- if (char_code === NEWLINE) {
1364
- break;
1365
- }
1297
+ // Parse inline content until end of line
1298
+ const content_nodes: Array<MdzNode> = [];
1366
1299
 
1300
+ while (this.#index < eol) {
1367
1301
  const node = this.#parse_node();
1368
1302
  if (node.type === 'Text') {
1369
- // Check if text node includes a newline (since #parse_text doesn't stop at single newlines)
1370
- // If so, trim it and move index back
1371
- const newline_index = node.content.indexOf('\n');
1372
- if (newline_index !== -1) {
1373
- const trimmed_content = node.content.slice(0, newline_index);
1303
+ // Trim if #parse_text overshot past the newline
1304
+ if (node.end > eol) {
1305
+ const trimmed_content = node.content.slice(0, eol - node.start);
1374
1306
  if (trimmed_content) {
1375
1307
  this.#accumulate_text(trimmed_content, node.start);
1376
1308
  }
1377
- this.#index = node.start + newline_index;
1309
+ this.#index = eol;
1378
1310
  break;
1379
- } else {
1380
- this.#accumulate_text(node.content, node.start);
1381
1311
  }
1312
+ this.#accumulate_text(node.content, node.start);
1382
1313
  } else {
1383
1314
  this.#flush_text();
1384
1315
  content_nodes.push(...this.#nodes);
@@ -1387,6 +1318,8 @@ export class MdzParser {
1387
1318
  }
1388
1319
  }
1389
1320
 
1321
+ this.#max_search_index = saved_max_search_index;
1322
+
1390
1323
  this.#flush_text();
1391
1324
  content_nodes.push(...this.#nodes);
1392
1325
  this.#nodes.length = 0;
@@ -1396,6 +1329,7 @@ export class MdzParser {
1396
1329
  return {
1397
1330
  type: 'Heading',
1398
1331
  level: level as 1 | 2 | 3 | 4 | 5 | 6,
1332
+ id: mdz_heading_id(content_nodes),
1399
1333
  children: content_nodes,
1400
1334
  start,
1401
1335
  end: this.#index,
@@ -1587,41 +1521,3 @@ export class MdzParser {
1587
1521
  throw Error('Code block not properly closed');
1588
1522
  }
1589
1523
  }
1590
-
1591
- /**
1592
- * Check if a string is a URL (`https://` or `http://`).
1593
- * Requires at least one valid character after the protocol.
1594
- * Rejects whitespace and characters that can't start a valid hostname.
1595
- */
1596
- const URL_PATTERN = /^https?:\/\/[^\s)\]}<>.,:/?#!]/;
1597
- export const mdz_is_url = (s: string): boolean => URL_PATTERN.test(s);
1598
-
1599
- /**
1600
- * Resolves a relative path (`./` or `../`) against a base path.
1601
- * The base is treated as a directory regardless of trailing slash
1602
- * (`'/docs/mdz'` and `'/docs/mdz/'` behave identically).
1603
- * Handles embedded `.` and `..` segments within the reference
1604
- * (e.g., `'./a/../b'` → navigates up then down).
1605
- * Clamps at root — excess `..` segments stop at `/` rather than escaping.
1606
- *
1607
- * @param reference A relative path starting with `./` or `../`.
1608
- * @param base An absolute base path (e.g., `'/docs/mdz/'`). Empty string is treated as root.
1609
- * @returns An absolute resolved path (e.g., `'/docs/mdz/grammar'`).
1610
- */
1611
- export const resolve_relative_path = (reference: string, base: string): string => {
1612
- const segments = base.split('/');
1613
- // Remove trailing empty from split (e.g., '/docs/mdz/' → ['', 'docs', 'mdz', ''])
1614
- // but keep the root segment ([''] from '' base or ['', ''] from '/').
1615
- if (segments.length > 1 && segments.at(-1) === '') segments.pop();
1616
- const trailing = reference.endsWith('/');
1617
- for (const segment of reference.split('/')) {
1618
- if (segment === '.' || segment === '') continue;
1619
- if (segment === '..') {
1620
- if (segments.length > 1) segments.pop(); // clamp at root
1621
- } else {
1622
- segments.push(segment);
1623
- }
1624
- }
1625
- if (trailing) segments.push('');
1626
- return segments.join('/');
1627
- };
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type {MdzNode, MdzComponentNode, MdzElementNode} from './mdz.js';
11
+ import {slugify} from '@fuzdev/fuz_util/path.js';
11
12
 
12
13
  // Character codes for performance
13
14
  export const BACKTICK = 96; // `
@@ -187,7 +188,7 @@ export const trim_trailing_punctuation = (url: string): string => {
187
188
  /**
188
189
  * Check if position in text is the start of an absolute path (starts with `/`).
189
190
  * Must be preceded by whitespace or be at the start of the string.
190
- * Rejects `//` (comments/protocol-relative) and `/ ` (bare slash).
191
+ * Rejects `//` (comments/protocol-relative) and slash followed by whitespace.
191
192
  */
192
193
  export const is_at_absolute_path = (text: string, index: number): boolean => {
193
194
  if (text.charCodeAt(index) !== SLASH) return false;
@@ -197,13 +198,13 @@ export const is_at_absolute_path = (text: string, index: number): boolean => {
197
198
  }
198
199
  if (index + 1 >= text.length) return false;
199
200
  const next_char = text.charCodeAt(index + 1);
200
- return next_char !== SLASH && next_char !== SPACE && next_char !== NEWLINE;
201
+ return next_char !== SLASH && next_char !== SPACE && next_char !== NEWLINE && next_char !== TAB;
201
202
  };
202
203
 
203
204
  /**
204
205
  * Check if position in text is the start of a relative path (`./` or `../`).
205
206
  * Must be preceded by whitespace or be at the start of the string.
206
- * Requires at least one path character after the prefix.
207
+ * Rejects prefix followed by whitespace, slash, or end of string.
207
208
  */
208
209
  export const is_at_relative_path = (text: string, index: number): boolean => {
209
210
  if (text.charCodeAt(index) !== PERIOD) return false;
@@ -219,16 +220,70 @@ export const is_at_relative_path = (text: string, index: number): boolean => {
219
220
  text.charCodeAt(index + 2) === SLASH
220
221
  ) {
221
222
  const after = text.charCodeAt(index + 3);
222
- return after !== SPACE && after !== NEWLINE && after !== SLASH;
223
+ return after !== SPACE && after !== NEWLINE && after !== TAB && after !== SLASH;
223
224
  }
224
225
  // Check for ./ (at least 3 chars: ./x)
225
226
  if (remaining >= 3 && text.charCodeAt(index + 1) === SLASH) {
226
227
  const after = text.charCodeAt(index + 2);
227
- return after !== SPACE && after !== NEWLINE && after !== SLASH;
228
+ return after !== SPACE && after !== NEWLINE && after !== TAB && after !== SLASH;
228
229
  }
229
230
  return false;
230
231
  };
231
232
 
233
+ /**
234
+ * Extracts plain text content from an array of mdz nodes, recursing into children.
235
+ */
236
+ export const mdz_text_content = (nodes: Array<MdzNode>): string =>
237
+ nodes
238
+ .map((n) => ('children' in n ? mdz_text_content(n.children) : 'content' in n ? n.content : ''))
239
+ .join('');
240
+
241
+ /**
242
+ * Generates a lowercase slug id for a heading from its child nodes.
243
+ * Follows standard markdown conventions (GitHub, etc.) where heading IDs are lowercased.
244
+ * For case-preserving IDs (e.g. API declarations), see `docs_slugify` in `docs_helpers.svelte.ts`.
245
+ */
246
+ export const mdz_heading_id = (nodes: Array<MdzNode>): string =>
247
+ slugify(mdz_text_content(nodes), false);
248
+
249
+ /**
250
+ * Check if a string is a URL (`https://` or `http://`).
251
+ * Requires at least one valid character after the protocol.
252
+ * Rejects whitespace and characters that can't start a valid hostname.
253
+ */
254
+ const URL_PATTERN = /^https?:\/\/[^\s)\]}<>.,:/?#!]/;
255
+ export const mdz_is_url = (s: string): boolean => URL_PATTERN.test(s);
256
+
257
+ /**
258
+ * Resolves a relative path (`./` or `../`) against a base path.
259
+ * The base is treated as a directory regardless of trailing slash
260
+ * (`'/docs/mdz'` and `'/docs/mdz/'` behave identically).
261
+ * Handles embedded `.` and `..` segments within the reference
262
+ * (e.g., `'./a/../b'` → navigates up then down).
263
+ * Clamps at root — excess `..` segments stop at `/` rather than escaping.
264
+ *
265
+ * @param reference - a relative path starting with `./` or `../`
266
+ * @param base - An absolute base path (e.g., `'/docs/mdz/'`). Empty string is treated as root.
267
+ * @returns an absolute resolved path (e.g., `'/docs/mdz/grammar'`)
268
+ */
269
+ export const resolve_relative_path = (reference: string, base: string): string => {
270
+ const segments = base.split('/');
271
+ // Remove trailing empty from split (e.g., '/docs/mdz/' → ['', 'docs', 'mdz', ''])
272
+ // but keep the root segment ([''] from '' base or ['', ''] from '/').
273
+ if (segments.length > 1 && segments.at(-1) === '') segments.pop();
274
+ const trailing = reference.endsWith('/');
275
+ for (const segment of reference.split('/')) {
276
+ if (segment === '.' || segment === '') continue;
277
+ if (segment === '..') {
278
+ if (segments.length > 1) segments.pop(); // clamp at root
279
+ } else {
280
+ segments.push(segment);
281
+ }
282
+ }
283
+ if (trailing) segments.push('');
284
+ return segments.join('/');
285
+ };
286
+
232
287
  export const extract_single_tag = (
233
288
  nodes: Array<MdzNode>,
234
289
  ): MdzComponentNode | MdzElementNode | null => {
@@ -7,8 +7,8 @@
7
7
  * @module
8
8
  */
9
9
 
10
- import {mdz_is_url} from './mdz.js';
11
10
  import {
11
+ mdz_is_url,
12
12
  is_letter,
13
13
  is_tag_name_char,
14
14
  is_word_char,
@@ -276,14 +276,17 @@ export class MdzLexer {
276
276
  end: this.#index, // end of "## " prefix
277
277
  });
278
278
 
279
+ // Find end-of-line to bound nested tokenizers (prevents tag scanner from scanning past heading)
280
+ let eol = this.#text.indexOf('\n', this.#index);
281
+ if (eol === -1) eol = this.#text.length;
282
+
283
+ const saved_max = this.#max_search_index;
284
+ this.#max_search_index = eol;
285
+
279
286
  // Tokenize inline content until newline or EOF
280
287
  // tokenize_text may consume a newline as part of block-element lookahead,
281
288
  // so we check emitted text tokens for embedded newlines and trim.
282
- while (this.#index < this.#text.length) {
283
- if (this.#text.charCodeAt(this.#index) === NEWLINE) {
284
- break;
285
- }
286
-
289
+ while (this.#index < eol) {
287
290
  const token_count_before = this.#tokens.length;
288
291
  this.#tokenize_inline();
289
292
 
@@ -307,6 +310,8 @@ export class MdzLexer {
307
310
  }
308
311
  }
309
312
 
313
+ this.#max_search_index = saved_max;
314
+
310
315
  // Emit heading_end marker so the token parser knows where heading content stops
311
316
  this.#tokens.push({type: 'heading_end', start: this.#index, end: this.#index});
312
317
 
@@ -798,11 +803,17 @@ export class MdzLexer {
798
803
  }
799
804
  this.#index++; // consume >
800
805
 
801
- // Check for closing tag existence before committing
806
+ // Check for closing tag existence before committing
807
+ // must exist within search boundary and before any paragraph break
802
808
  const closing_tag = `</${tag_name}>`;
809
+ const search_limit = Math.min(this.#max_search_index, this.#text.length);
803
810
  const closing_tag_pos = this.#text.indexOf(closing_tag, this.#index);
804
- if (closing_tag_pos === -1) {
805
- // No closing tag - revert
811
+ if (closing_tag_pos === -1 || closing_tag_pos >= search_limit) {
812
+ this.#index = start + 1;
813
+ this.#emit_text('<', start);
814
+ return;
815
+ }
816
+ if (this.#has_paragraph_break_between(this.#index, closing_tag_pos)) {
806
817
  this.#index = start + 1;
807
818
  this.#emit_text('<', start);
808
819
  return;
@@ -12,7 +12,8 @@ import {UnreachableError} from '@fuzdev/fuz_util/error.js';
12
12
  import {escape_svelte_text} from '@fuzdev/fuz_util/svelte_preprocess_helpers.js';
13
13
  import {escape_js_string} from '@fuzdev/fuz_util/string.js';
14
14
 
15
- import {type MdzNode, resolve_relative_path} from './mdz.js';
15
+ import type {MdzNode} from './mdz.js';
16
+ import {resolve_relative_path} from './mdz_helpers.js';
16
17
 
17
18
  /**
18
19
  * Result of converting `MdzNode` arrays to Svelte markup.
@@ -34,12 +35,12 @@ export interface MdzToSvelteResult {
34
35
  * Each node type produces output matching what `MdzNodeView.svelte` renders at runtime.
35
36
  * Collects required imports and flags unconfigured component/element references.
36
37
  *
37
- * @param nodes Parsed mdz nodes to render.
38
- * @param components Component name to import path mapping (e.g., `{Alert: '$lib/Alert.svelte'}`).
38
+ * @param nodes - parsed mdz nodes to render
39
+ * @param components - Component name to import path mapping (e.g., `{Alert: '$lib/Alert.svelte'}`)
39
40
  * If content references a component not in this map, `has_unconfigured_tags` is set.
40
- * @param elements Allowed HTML element names (e.g., `new Set(['aside', 'details'])`).
41
+ * @param elements - Allowed HTML element names (e.g., `new Set(['aside', 'details'])`)
41
42
  * If content references an element not in this set, `has_unconfigured_tags` is set.
42
- * @param base Base path for resolving relative links (e.g., `'/docs/mdz/'`).
43
+ * @param base - Base path for resolving relative links (e.g., `'/docs/mdz/'`)
43
44
  * When provided, relative references (`./`, `../`) are resolved to absolute paths
44
45
  * and passed through `resolve()`. Trailing slash recommended.
45
46
  */
@@ -18,7 +18,7 @@ import type {
18
18
  MdzElementNode,
19
19
  MdzComponentNode,
20
20
  } from './mdz.js';
21
- import {extract_single_tag} from './mdz_helpers.js';
21
+ import {extract_single_tag, mdz_heading_id} from './mdz_helpers.js';
22
22
  import {
23
23
  MdzLexer,
24
24
  type MdzToken,
@@ -124,9 +124,10 @@ class MdzTokenParser {
124
124
  if (node) children.push(node);
125
125
  }
126
126
 
127
- const end = children.length > 0 ? children[children.length - 1]!.end : start + level + 1;
127
+ const merged = this.#merge_adjacent_text(children);
128
+ const end = merged.length > 0 ? merged[merged.length - 1]!.end : start + level + 1;
128
129
 
129
- return {type: 'Heading', level, children, start, end};
130
+ return {type: 'Heading', level, id: mdz_heading_id(merged), children: merged, start, end};
130
131
  }
131
132
 
132
133
  #parse_inline(): MdzNode | null {
@@ -174,8 +174,8 @@ export const MODULE_SOURCE_PARTIAL: ModuleSourcePartial = {
174
174
  /**
175
175
  * Create complete source options from project root and optional overrides.
176
176
  *
177
- * @param project_root Absolute path to project root (typically `process.cwd()`)
178
- * @param overrides Optional overrides for default options
177
+ * @param project_root - absolute path to project root (typically `process.cwd()`)
178
+ * @param overrides - optional overrides for default options
179
179
  *
180
180
  * @example
181
181
  * ```ts
@@ -348,8 +348,8 @@ export const module_get_source_root = (options: ModuleSourceOptions): string =>
348
348
  *
349
349
  * Uses proper path semantics: strips `project_root/source_root/` prefix.
350
350
  *
351
- * @param source_id Absolute path to the source file
352
- * @param options Module source options for path extraction
351
+ * @param source_id - absolute path to the source file
352
+ * @param options - module source options for path extraction
353
353
  *
354
354
  * @example
355
355
  * ```ts
@@ -430,9 +430,9 @@ export const module_is_test = (path: string): boolean => path.endsWith('.test.ts
430
430
  * `project_root/source_path/`. No heuristics needed - nested directories
431
431
  * are correctly excluded by the prefix check.
432
432
  *
433
- * @param path Full absolute path to check
434
- * @param options Module source options for filtering
435
- * @returns True if the path is an analyzable source file
433
+ * @param path - full absolute path to check
434
+ * @param options - module source options for filtering
435
+ * @returns true if the path is an analyzable source file
436
436
  *
437
437
  * @example
438
438
  * ```ts
@@ -465,8 +465,8 @@ export const module_is_source = (path: string, options: ModuleSourceOptions): bo
465
465
  * Filters to only include source modules (excludes external packages, node_modules, tests).
466
466
  * Returns sorted arrays of module paths (relative to source_root) for deterministic output.
467
467
  *
468
- * @param source_file The source file info to extract dependencies from
469
- * @param options Module source options for filtering and path extraction
468
+ * @param source_file - the source file info to extract dependencies from
469
+ * @param options - module source options for filtering and path extraction
470
470
  */
471
471
  export const module_extract_dependencies = (
472
472
  source_file: SourceFileInfo,