@fuzdev/fuz_ui 0.189.1 → 0.191.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 (78) hide show
  1. package/dist/ContextmenuEntry.svelte +3 -3
  2. package/dist/ContextmenuLinkEntry.svelte +3 -3
  3. package/dist/ContextmenuRootForSafariCompatibility.svelte +1 -1
  4. package/dist/ContextmenuSubmenu.svelte +3 -3
  5. package/dist/DocsMenu.svelte +1 -1
  6. package/dist/DocsPageLinks.svelte +1 -1
  7. package/dist/DocsTertiaryNav.svelte +3 -3
  8. package/dist/MdzNodeView.svelte +3 -2
  9. package/dist/MdzNodeView.svelte.d.ts +1 -1
  10. package/dist/MdzNodeView.svelte.d.ts.map +1 -1
  11. package/dist/analysis_context.d.ts +2 -2
  12. package/dist/analysis_context.js +2 -2
  13. package/dist/contextmenu_state.svelte.d.ts +7 -7
  14. package/dist/contextmenu_state.svelte.js +7 -7
  15. package/dist/docs_helpers.svelte.d.ts +7 -5
  16. package/dist/docs_helpers.svelte.d.ts.map +1 -1
  17. package/dist/docs_helpers.svelte.js +7 -5
  18. package/dist/intersect.svelte.d.ts +1 -1
  19. package/dist/intersect.svelte.js +1 -1
  20. package/dist/library_analysis.d.ts +5 -5
  21. package/dist/library_analysis.js +5 -5
  22. package/dist/library_gen.d.ts +4 -4
  23. package/dist/library_gen.js +4 -4
  24. package/dist/library_generate.d.ts +2 -2
  25. package/dist/library_helpers.d.ts +8 -8
  26. package/dist/library_helpers.js +8 -8
  27. package/dist/library_pipeline.d.ts +5 -5
  28. package/dist/library_pipeline.js +5 -5
  29. package/dist/mdz.d.ts +1 -14
  30. package/dist/mdz.d.ts.map +1 -1
  31. package/dist/mdz.js +57 -156
  32. package/dist/mdz_helpers.d.ts +26 -2
  33. package/dist/mdz_helpers.d.ts.map +1 -1
  34. package/dist/mdz_helpers.js +59 -5
  35. package/dist/mdz_lexer.d.ts.map +1 -1
  36. package/dist/mdz_lexer.js +18 -9
  37. package/dist/mdz_to_svelte.d.ts +5 -5
  38. package/dist/mdz_to_svelte.d.ts.map +1 -1
  39. package/dist/mdz_to_svelte.js +5 -5
  40. package/dist/mdz_token_parser.js +4 -3
  41. package/dist/module_helpers.d.ts +8 -8
  42. package/dist/module_helpers.js +8 -8
  43. package/dist/package_helpers.d.ts +12 -12
  44. package/dist/package_helpers.js +12 -12
  45. package/dist/storage.d.ts +3 -3
  46. package/dist/storage.js +3 -3
  47. package/dist/svelte_helpers.d.ts +9 -9
  48. package/dist/svelte_helpers.js +9 -9
  49. package/dist/svelte_preprocess_mdz.d.ts +1 -1
  50. package/dist/svelte_preprocess_mdz.js +1 -1
  51. package/dist/ts_helpers.d.ts +19 -19
  52. package/dist/ts_helpers.js +19 -19
  53. package/dist/tsdoc_helpers.d.ts +5 -5
  54. package/dist/tsdoc_helpers.js +5 -5
  55. package/dist/tsdoc_mdz.js +1 -1
  56. package/package.json +7 -6
  57. package/src/lib/analysis_context.ts +2 -2
  58. package/src/lib/contextmenu_state.svelte.ts +7 -7
  59. package/src/lib/docs_helpers.svelte.ts +7 -5
  60. package/src/lib/intersect.svelte.ts +1 -1
  61. package/src/lib/library_analysis.ts +5 -5
  62. package/src/lib/library_gen.ts +4 -4
  63. package/src/lib/library_generate.ts +2 -2
  64. package/src/lib/library_helpers.ts +8 -8
  65. package/src/lib/library_pipeline.ts +5 -5
  66. package/src/lib/mdz.ts +63 -167
  67. package/src/lib/mdz_helpers.ts +60 -5
  68. package/src/lib/mdz_lexer.ts +20 -9
  69. package/src/lib/mdz_to_svelte.ts +6 -5
  70. package/src/lib/mdz_token_parser.ts +4 -3
  71. package/src/lib/module_helpers.ts +8 -8
  72. package/src/lib/package_helpers.ts +12 -12
  73. package/src/lib/storage.ts +3 -3
  74. package/src/lib/svelte_helpers.ts +9 -9
  75. package/src/lib/svelte_preprocess_mdz.ts +1 -1
  76. package/src/lib/ts_helpers.ts +19 -19
  77. package/src/lib/tsdoc_helpers.ts +5 -5
  78. package/src/lib/tsdoc_mdz.ts +1 -1
@@ -344,7 +344,7 @@ let cache_key_counter = 0;
344
344
 
345
345
  /**
346
346
  * Creates an attachment that sets up contextmenu behavior on an element.
347
- * @param params Contextmenu parameters or nullish to disable
347
+ * @param params - contextmenu parameters or nullish to disable
348
348
  */
349
349
  export const contextmenu_attachment =
350
350
  <T extends ContextmenuParams, U extends T | Array<T>>(
@@ -380,11 +380,11 @@ export interface ContextmenuOpenOptions {
380
380
  /**
381
381
  * Opens the contextmenu, if appropriate,
382
382
  * querying the menu items from the DOM starting at the event target.
383
- * @param target the leaf element from which to open the contextmenu
384
- * @param x the page X coordinate at which to open the contextmenu, typically the mouse `pageX`
385
- * @param y the page Y coordinate at which to open the contextmenu, typically the mouse `pageY`
386
- * @param contextmenu the contextmenu store
387
- * @param options optional configuration for filtering entries and haptic feedback
383
+ * @param target - the leaf element from which to open the contextmenu
384
+ * @param x - the page X coordinate at which to open the contextmenu, typically the mouse `pageX`
385
+ * @param y - the page Y coordinate at which to open the contextmenu, typically the mouse `pageY`
386
+ * @param contextmenu - the contextmenu store
387
+ * @param options - optional configuration for filtering entries and haptic feedback
388
388
  * @returns a boolean indicating if the menu was opened or not
389
389
  */
390
390
  export const contextmenu_open = (
@@ -487,7 +487,7 @@ const non_scoped_roots: Set<symbol> = new Set();
487
487
  * Registers a contextmenu root and warns if multiple non-scoped roots are detected.
488
488
  * Only active in development mode. Automatically handles cleanup on unmount.
489
489
  *
490
- * @param get_scoped Getter function that returns the current scoped value
490
+ * @param get_scoped - getter function that returns the current scoped value
491
491
  */
492
492
  export const contextmenu_check_global_root = (get_scoped: () => boolean): void => {
493
493
  $effect(() => {
@@ -5,11 +5,13 @@ import {ensure_end, ensure_start} from '@fuzdev/fuz_util/string.js';
5
5
  import {create_context} from './context_helpers.js';
6
6
 
7
7
  /**
8
- * Convert a string to a URL-safe fragment identifier, preserving case for API declarations.
9
- * Only transforms spaces and special characters, keeping valid identifier characters intact.
10
- * Used for hash anchors in documentation.
11
- * @param str - The string to convert to a fragment
12
- * @returns A URL-safe fragment identifier
8
+ * Convert a string to a URL-safe fragment identifier, preserving case.
9
+ * Unlike `slugify` from `@fuzdev/fuz_util/path.js` which lowercases,
10
+ * this keeps the original casing so API declarations like `AsyncStatus`
11
+ * and `async_status` produce distinct fragment IDs.
12
+ * Used by the Tome documentation system for heading and section anchors.
13
+ * @param str - the string to convert to a fragment
14
+ * @returns a URL-safe fragment identifier with case preserved
13
15
  */
14
16
  export const docs_slugify = (str: string): string => {
15
17
  return (
@@ -30,7 +30,7 @@ export type IntersectParamsOrCallback = OnIntersect | IntersectParams;
30
30
  * Creates an attachment that observes element viewport intersection.
31
31
  * Uses the lazy function pattern to optimize reactivity:
32
32
  * callbacks can update without recreating the observer, preserving state.
33
- * @param get_params Function that returns callback, params object, or nullish to disable
33
+ * @param get_params - function that returns callback, params object, or nullish to disable
34
34
  */
35
35
  export const intersect =
36
36
  (
@@ -113,11 +113,11 @@ export interface ModuleAnalysis {
113
113
  * only re-analyze changed files. The TypeScript program should include all files
114
114
  * for accurate type resolution, but only changed files need re-analysis.
115
115
  *
116
- * @param source_file The source file info with content and optional dependency data
117
- * @param program TypeScript program (used for type checking and source file lookup)
118
- * @param options Module source options for path extraction
119
- * @param ctx Analysis context for collecting diagnostics
120
- * @param log Optional logger for warnings
116
+ * @param source_file - the source file info with content and optional dependency data
117
+ * @param program - TypeScript program (used for type checking and source file lookup)
118
+ * @param options - module source options for path extraction
119
+ * @param ctx - analysis context for collecting diagnostics
120
+ * @param log - optional logger for warnings
121
121
  * @returns Module metadata and re-exports, or undefined if source file not found in program
122
122
  */
123
123
  export const library_analyze_module = (
@@ -89,9 +89,9 @@ export const source_file_from_disknode = (disknode: Disknode): SourceFileInfo =>
89
89
  * have malformed paths or missing content). The filtering uses `module_is_source` which
90
90
  * checks `source_paths` to only include files in configured source directories.
91
91
  *
92
- * @param disknodes Iterator of Gro disknodes from filer
93
- * @param options Module source options for filtering
94
- * @param log Optional logger for status messages
92
+ * @param disknodes - iterator of Gro disknodes from filer
93
+ * @param options - module source options for filtering
94
+ * @param log - optional logger for status messages
95
95
  */
96
96
  export const library_collect_source_files_from_disknodes = (
97
97
  disknodes: Iterable<Disknode>,
@@ -146,7 +146,7 @@ export const library_collect_source_files_from_disknodes = (
146
146
  * export const gen = library_gen();
147
147
  * ```
148
148
  *
149
- * @param options Optional generation options
149
+ * @param options - optional generation options
150
150
  */
151
151
  export const library_gen = (options?: LibraryGenOptions): Gen => {
152
152
  return {
@@ -43,8 +43,8 @@ import {AnalysisContext, format_diagnostic} from './analysis_context.js';
43
43
  /**
44
44
  * Callback for handling duplicate declaration names.
45
45
  *
46
- * @param duplicates Map of declaration names to their occurrences across modules
47
- * @param log Logger for reporting
46
+ * @param duplicates - map of declaration names to their occurrences across modules
47
+ * @param log - logger for reporting
48
48
  */
49
49
  export type OnDuplicatesCallback = (
50
50
  duplicates: Map<string, Array<DuplicateInfo>>,
@@ -17,7 +17,7 @@ import {DOCS_API_PATH, DOCS_PATH_DEFAULT} from './docs_helpers.svelte.js';
17
17
  /**
18
18
  * Build project-relative API documentation URL with hash anchor.
19
19
  *
20
- * @param declaration_name Name of the declaration to link to
20
+ * @param declaration_name - name of the declaration to link to
21
21
  * @returns URL path like '/docs/api#declaration_name'
22
22
  */
23
23
  export const url_api_declaration = (declaration_name: string): string =>
@@ -26,8 +26,8 @@ export const url_api_declaration = (declaration_name: string): string =>
26
26
  /**
27
27
  * Build full API documentation URL with domain and hash anchor.
28
28
  *
29
- * @param homepage Package homepage URL
30
- * @param declaration_name Name of the declaration to link to
29
+ * @param homepage - package homepage URL
30
+ * @param declaration_name - name of the declaration to link to
31
31
  * @returns Full URL like 'https://example.com/docs/api#declaration_name'
32
32
  */
33
33
  export const url_api_declaration_full = (homepage: string, declaration_name: string): string =>
@@ -36,7 +36,7 @@ export const url_api_declaration_full = (homepage: string, declaration_name: str
36
36
  /**
37
37
  * Build project-relative module documentation URL.
38
38
  *
39
- * @param module_path Module path (e.g., 'helpers.ts')
39
+ * @param module_path - module path (e.g., 'helpers.ts')
40
40
  * @returns URL path like '/docs/api/helpers.ts'
41
41
  */
42
42
  export const url_api_module = (module_path: string): string => `${DOCS_API_PATH}/${module_path}`;
@@ -44,8 +44,8 @@ export const url_api_module = (module_path: string): string => `${DOCS_API_PATH}
44
44
  /**
45
45
  * Build package logo URL with favicon.png fallback.
46
46
  *
47
- * @param homepage_url Package homepage URL, or null
48
- * @param logo_path Optional custom logo path (defaults to 'favicon.png')
47
+ * @param homepage_url - package homepage URL, or null
48
+ * @param logo_path - optional custom logo path (defaults to 'favicon.png')
49
49
  * @returns Full URL to the logo, or null if no homepage
50
50
  */
51
51
  export const url_package_logo = (
@@ -62,8 +62,8 @@ export const url_package_logo = (
62
62
  *
63
63
  * Uses SvelteKit's page state for the current origin by default.
64
64
  *
65
- * @param url Full URL to convert
66
- * @param origin Origin to strip (defaults to current page origin)
65
+ * @param url - full URL to convert
66
+ * @param origin - origin to strip (defaults to current page origin)
67
67
  * @returns Root-relative URL starting with '/'
68
68
  *
69
69
  * @example
@@ -130,8 +130,8 @@ export interface CollectedReExport {
130
130
  * // - helpers.ts foo declaration gets: also_exported_from: ['index.ts']
131
131
  * // - helpers.ts bar declaration gets: also_exported_from: ['index.ts']
132
132
  *
133
- * @param source_json The source JSON with all modules (will be mutated)
134
- * @param collected_re_exports Array of re-exports collected during phase 1
133
+ * @param source_json - the source JSON with all modules (will be mutated)
134
+ * @param collected_re_exports - array of re-exports collected during phase 1
135
135
  * @mutates source_json - adds `also_exported_from` to declarations
136
136
  */
137
137
  export const library_merge_re_exports = (
@@ -180,9 +180,9 @@ export const library_merge_re_exports = (
180
180
  * File types are determined by `options.get_analyzer`. By default, `.ts`, `.js`, and `.svelte`
181
181
  * files are supported. Customize `get_analyzer` to support additional file types like `.svx`.
182
182
  *
183
- * @param files Iterable of source file info (from Gro filer, file system, or other source)
184
- * @param options Module source options for filtering
185
- * @param log Optional logger for status messages
183
+ * @param files - iterable of source file info (from Gro filer, file system, or other source)
184
+ * @param options - module source options for filtering
185
+ * @param log - optional logger for status messages
186
186
  */
187
187
  export const library_collect_source_files = (
188
188
  files: Iterable<SourceFileInfo>,
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
 
@@ -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
  /**
@@ -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 => {