@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.
- package/dist/ContextmenuRootForSafariCompatibility.svelte +2 -2
- package/dist/MdzNodeView.svelte +3 -2
- package/dist/MdzNodeView.svelte.d.ts +1 -1
- package/dist/MdzNodeView.svelte.d.ts.map +1 -1
- package/dist/ProjectLinks.svelte +4 -2
- package/dist/ProjectLinks.svelte.d.ts.map +1 -1
- package/dist/analysis_context.d.ts +3 -3
- package/dist/analysis_context.js +3 -3
- package/dist/contextmenu_state.svelte.d.ts +7 -7
- package/dist/contextmenu_state.svelte.js +7 -7
- package/dist/docs_helpers.svelte.d.ts +7 -5
- package/dist/docs_helpers.svelte.d.ts.map +1 -1
- package/dist/docs_helpers.svelte.js +7 -5
- package/dist/intersect.svelte.d.ts +1 -1
- package/dist/intersect.svelte.js +1 -1
- package/dist/library_analysis.d.ts +6 -6
- package/dist/library_analysis.js +6 -6
- package/dist/library_gen.d.ts +4 -4
- package/dist/library_gen.js +4 -4
- package/dist/library_generate.d.ts +2 -2
- package/dist/library_helpers.d.ts +11 -11
- package/dist/library_helpers.js +11 -11
- package/dist/library_pipeline.d.ts +5 -5
- package/dist/library_pipeline.js +5 -5
- package/dist/logos.d.ts +7 -0
- package/dist/logos.d.ts.map +1 -1
- package/dist/logos.js +5 -0
- package/dist/mdz.d.ts +1 -14
- package/dist/mdz.d.ts.map +1 -1
- package/dist/mdz.js +64 -163
- package/dist/mdz_helpers.d.ts +26 -2
- package/dist/mdz_helpers.d.ts.map +1 -1
- package/dist/mdz_helpers.js +59 -5
- package/dist/mdz_lexer.d.ts.map +1 -1
- package/dist/mdz_lexer.js +18 -9
- package/dist/mdz_to_svelte.d.ts +5 -5
- package/dist/mdz_to_svelte.d.ts.map +1 -1
- package/dist/mdz_to_svelte.js +5 -5
- package/dist/mdz_token_parser.js +4 -3
- package/dist/module_helpers.d.ts +9 -9
- package/dist/module_helpers.js +9 -9
- package/dist/package_helpers.d.ts +20 -20
- package/dist/package_helpers.js +20 -20
- package/dist/storage.d.ts +4 -4
- package/dist/storage.js +4 -4
- package/dist/svelte_helpers.d.ts +13 -13
- package/dist/svelte_helpers.js +13 -13
- package/dist/svelte_preprocess_mdz.d.ts +2 -2
- package/dist/svelte_preprocess_mdz.js +2 -2
- package/dist/tome.d.ts +1 -1
- package/dist/tome.js +1 -1
- package/dist/ts_helpers.d.ts +24 -24
- package/dist/ts_helpers.js +24 -24
- package/dist/tsdoc_helpers.d.ts +6 -6
- package/dist/tsdoc_helpers.js +6 -6
- package/dist/tsdoc_mdz.js +1 -1
- package/package.json +8 -7
- package/src/lib/analysis_context.ts +3 -3
- package/src/lib/contextmenu_state.svelte.ts +7 -7
- package/src/lib/docs_helpers.svelte.ts +7 -5
- package/src/lib/intersect.svelte.ts +1 -1
- package/src/lib/library_analysis.ts +6 -6
- package/src/lib/library_gen.ts +4 -4
- package/src/lib/library_generate.ts +2 -2
- package/src/lib/library_helpers.ts +11 -11
- package/src/lib/library_pipeline.ts +5 -5
- package/src/lib/logos.ts +6 -0
- package/src/lib/mdz.ts +73 -177
- package/src/lib/mdz_helpers.ts +60 -5
- package/src/lib/mdz_lexer.ts +20 -9
- package/src/lib/mdz_to_svelte.ts +6 -5
- package/src/lib/mdz_token_parser.ts +4 -3
- package/src/lib/module_helpers.ts +9 -9
- package/src/lib/package_helpers.ts +20 -20
- package/src/lib/storage.ts +4 -4
- package/src/lib/svelte_helpers.ts +13 -13
- package/src/lib/svelte_preprocess_mdz.ts +2 -2
- package/src/lib/tome.ts +1 -1
- package/src/lib/ts_helpers.ts +24 -24
- package/src/lib/tsdoc_helpers.ts +6 -6
- 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 -
|
|
494
|
-
* @param node_type -
|
|
495
|
-
* @returns
|
|
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
|
-
//
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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.#
|
|
750
|
-
|
|
751
|
-
this.#
|
|
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.#
|
|
774
|
-
|
|
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
|
-
|
|
740
|
+
const tag_name = this.#template.slice(start + 1, i);
|
|
797
741
|
const first_char_code = tag_name.charCodeAt(0);
|
|
798
|
-
const
|
|
799
|
-
|
|
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
|
-
|
|
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
|
-
|
|
814
|
-
this.#template.charCodeAt(
|
|
815
|
-
this.#template.charCodeAt(
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
849
|
-
this.#index++;
|
|
774
|
+
const content_start = i + 1; // past >
|
|
850
775
|
|
|
851
|
-
//
|
|
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
|
-
//
|
|
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 -
|
|
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 -
|
|
952
|
-
* @param check_before -
|
|
953
|
-
* @param check_after -
|
|
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 -
|
|
1177
|
-
* @param end_index -
|
|
1178
|
-
* @returns
|
|
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
|
-
//
|
|
1358
|
-
|
|
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
|
-
|
|
1361
|
-
|
|
1294
|
+
const saved_max_search_index = this.#max_search_index;
|
|
1295
|
+
this.#max_search_index = eol;
|
|
1362
1296
|
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
//
|
|
1370
|
-
|
|
1371
|
-
|
|
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 =
|
|
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
|
-
};
|
package/src/lib/mdz_helpers.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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 => {
|
package/src/lib/mdz_lexer.ts
CHANGED
|
@@ -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 <
|
|
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
|
-
|
|
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;
|
package/src/lib/mdz_to_svelte.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
178
|
-
* @param overrides
|
|
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
|
|
352
|
-
* @param options
|
|
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
|
|
434
|
-
* @param options
|
|
435
|
-
* @returns
|
|
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
|
|
469
|
-
* @param options
|
|
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,
|