@fuzdev/fuz_ui 0.185.2 → 0.187.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 (44) hide show
  1. package/dist/ApiModule.svelte +22 -6
  2. package/dist/ApiModule.svelte.d.ts.map +1 -1
  3. package/dist/DeclarationLink.svelte +1 -1
  4. package/dist/DocsLink.svelte +2 -2
  5. package/dist/DocsTertiaryNav.svelte +2 -2
  6. package/dist/Mdz.svelte +5 -0
  7. package/dist/Mdz.svelte.d.ts +1 -0
  8. package/dist/Mdz.svelte.d.ts.map +1 -1
  9. package/dist/MdzNodeView.svelte +19 -8
  10. package/dist/MdzNodeView.svelte.d.ts +1 -1
  11. package/dist/MdzNodeView.svelte.d.ts.map +1 -1
  12. package/dist/ModuleLink.svelte +1 -1
  13. package/dist/TypeLink.svelte +1 -1
  14. package/dist/library.svelte.d.ts +24 -27
  15. package/dist/library.svelte.d.ts.map +1 -1
  16. package/dist/library.svelte.js +16 -16
  17. package/dist/mdz.d.ts +13 -0
  18. package/dist/mdz.d.ts.map +1 -1
  19. package/dist/mdz.js +73 -280
  20. package/dist/mdz_components.d.ts +12 -0
  21. package/dist/mdz_components.d.ts.map +1 -1
  22. package/dist/mdz_components.js +8 -0
  23. package/dist/mdz_helpers.d.ts +108 -0
  24. package/dist/mdz_helpers.d.ts.map +1 -0
  25. package/dist/mdz_helpers.js +237 -0
  26. package/dist/mdz_lexer.d.ts +93 -0
  27. package/dist/mdz_lexer.d.ts.map +1 -0
  28. package/dist/mdz_lexer.js +727 -0
  29. package/dist/mdz_to_svelte.d.ts +5 -2
  30. package/dist/mdz_to_svelte.d.ts.map +1 -1
  31. package/dist/mdz_to_svelte.js +13 -2
  32. package/dist/mdz_token_parser.d.ts +14 -0
  33. package/dist/mdz_token_parser.d.ts.map +1 -0
  34. package/dist/mdz_token_parser.js +374 -0
  35. package/dist/svelte_preprocess_mdz.js +23 -7
  36. package/package.json +10 -9
  37. package/src/lib/library.svelte.ts +36 -35
  38. package/src/lib/mdz.ts +106 -302
  39. package/src/lib/mdz_components.ts +9 -0
  40. package/src/lib/mdz_helpers.ts +251 -0
  41. package/src/lib/mdz_lexer.ts +1003 -0
  42. package/src/lib/mdz_to_svelte.ts +15 -2
  43. package/src/lib/mdz_token_parser.ts +460 -0
  44. package/src/lib/svelte_preprocess_mdz.ts +23 -7
package/src/lib/mdz.ts CHANGED
@@ -29,6 +29,41 @@
29
29
  * @module
30
30
  */
31
31
 
32
+ import {
33
+ BACKTICK,
34
+ ASTERISK,
35
+ UNDERSCORE,
36
+ TILDE,
37
+ NEWLINE,
38
+ HYPHEN,
39
+ HASH,
40
+ SPACE,
41
+ TAB,
42
+ LEFT_ANGLE,
43
+ RIGHT_ANGLE,
44
+ SLASH,
45
+ LEFT_BRACKET,
46
+ RIGHT_BRACKET,
47
+ LEFT_PAREN,
48
+ RIGHT_PAREN,
49
+ A_UPPER,
50
+ Z_UPPER,
51
+ HR_HYPHEN_COUNT,
52
+ MIN_CODEBLOCK_BACKTICKS,
53
+ MAX_HEADING_LEVEL,
54
+ HTTPS_PREFIX_LENGTH,
55
+ HTTP_PREFIX_LENGTH,
56
+ is_letter,
57
+ is_tag_name_char,
58
+ is_word_char,
59
+ PERIOD,
60
+ is_valid_path_char,
61
+ trim_trailing_punctuation,
62
+ is_at_absolute_path,
63
+ is_at_relative_path,
64
+ extract_single_tag,
65
+ } from './mdz_helpers.js';
66
+
32
67
  // TODO design incremental parsing or some system that preserves Svelte components across re-renders when possible
33
68
 
34
69
  /**
@@ -91,7 +126,7 @@ export interface MdzLinkNode extends MdzBaseNode {
91
126
  type: 'Link';
92
127
  reference: string; // URL or path
93
128
  children: Array<MdzNode>; // Display content (can include inline formatting)
94
- link_type: 'external' | 'internal'; // external: https/http, internal: /path
129
+ link_type: 'external' | 'internal'; // external: https/http, internal: /path, ./path, ../path
95
130
  }
96
131
 
97
132
  export interface MdzParagraphNode extends MdzBaseNode {
@@ -121,51 +156,6 @@ export interface MdzComponentNode extends MdzBaseNode {
121
156
  children: Array<MdzNode>;
122
157
  }
123
158
 
124
- // Character codes for performance
125
- const BACKTICK = 96; // `
126
- const ASTERISK = 42; // *
127
- const UNDERSCORE = 95; // _
128
- const TILDE = 126; // ~
129
- const NEWLINE = 10; // \n
130
- const HYPHEN = 45; // -
131
- const HASH = 35; // #
132
- const SPACE = 32; // (space)
133
- const TAB = 9; // \t
134
- const LEFT_ANGLE = 60; // <
135
- const RIGHT_ANGLE = 62; // >
136
- const SLASH = 47; // /
137
- const LEFT_BRACKET = 91; // [
138
- const RIGHT_BRACKET = 93; // ]
139
- const LEFT_PAREN = 40; // (
140
- const RIGHT_PAREN = 41; // )
141
- const COLON = 58; // :
142
- const PERIOD = 46; // .
143
- const COMMA = 44; // ,
144
- const SEMICOLON = 59; // ;
145
- const EXCLAMATION = 33; // !
146
- const QUESTION = 63; // ?
147
- // RFC 3986 URI characters
148
- const DOLLAR = 36; // $
149
- const PERCENT = 37; // %
150
- const AMPERSAND = 38; // &
151
- const APOSTROPHE = 39; // '
152
- const PLUS = 43; // +
153
- const EQUALS = 61; // =
154
- const AT = 64; // @
155
- // Character ranges
156
- const A_UPPER = 65; // A
157
- const Z_UPPER = 90; // Z
158
- const A_LOWER = 97; // a
159
- const Z_LOWER = 122; // z
160
- const ZERO = 48; // 0
161
- const NINE = 57; // 9
162
- // mdz specification constants
163
- const HR_HYPHEN_COUNT = 3; // Horizontal rule requires exactly 3 hyphens
164
- const MIN_CODEBLOCK_BACKTICKS = 3; // Code blocks require minimum 3 backticks
165
- const MAX_HEADING_LEVEL = 6; // Headings support levels 1-6
166
- const HTTPS_PREFIX_LENGTH = 8; // Length of "https://"
167
- const HTTP_PREFIX_LENGTH = 7; // Length of "http://"
168
-
169
159
  /**
170
160
  * Parser for mdz format.
171
161
  * Single-pass lexer/parser with text accumulation for efficiency.
@@ -199,9 +189,10 @@ export class MdzParser {
199
189
  this.#skip_newlines();
200
190
 
201
191
  while (this.#index < this.#template.length) {
202
- // Peek for block element (read-only match), flush paragraph first, then parse.
203
- // Flush must happen before parse because parse modifies accumulation state.
204
- const block_type = this.#peek_block_element();
192
+ // Block elements only start at column 0 skip peek for mid-line characters
193
+ const block_type =
194
+ (this.#index === 0 || this.#template.charCodeAt(this.#index - 1) === NEWLINE) &&
195
+ this.#peek_block_element();
205
196
  if (block_type) {
206
197
  const flushed = this.#flush_paragraph(paragraph_children, true);
207
198
  if (flushed) root_nodes.push(flushed);
@@ -283,7 +274,7 @@ export class MdzParser {
283
274
  }
284
275
 
285
276
  // Single tag (component/element) - add directly without paragraph wrapper (MDX convention)
286
- const single_tag = this.#extract_single_tag(paragraph_children);
277
+ const single_tag = extract_single_tag(paragraph_children);
287
278
  if (single_tag) {
288
279
  paragraph_children.length = 0;
289
280
  return single_tag;
@@ -353,7 +344,7 @@ export class MdzParser {
353
344
  * Uses switch for performance (avoids regex in hot loop).
354
345
  */
355
346
  #parse_node(): MdzNode {
356
- const char_code = this.#current_char();
347
+ const char_code = this.#template.charCodeAt(this.#index);
357
348
 
358
349
  // Use character codes for performance in hot path
359
350
  switch (char_code) {
@@ -683,7 +674,7 @@ export class MdzParser {
683
674
  // Follows GFM behavior: invalid chars cause fallback to text, then auto-detection
684
675
  for (let i = 0; i < reference.length; i++) {
685
676
  const char_code = reference.charCodeAt(i);
686
- if (!this.#is_valid_path_char(char_code)) {
677
+ if (!is_valid_path_char(char_code)) {
687
678
  // Invalid character in URL, treat as text and let auto-detection handle it
688
679
  this.#index = start + 1;
689
680
  return {
@@ -767,7 +758,7 @@ export class MdzParser {
767
758
  }
768
759
 
769
760
  const first_char = this.#template.charCodeAt(this.#index);
770
- if (!this.#is_letter(first_char)) {
761
+ if (!is_letter(first_char)) {
771
762
  // Not a valid tag, treat as text - restore parent state
772
763
  this.#restore_accumulation_state(saved_state);
773
764
  return {
@@ -781,7 +772,7 @@ export class MdzParser {
781
772
  // Collect tag name (letters, numbers, hyphens, underscores)
782
773
  while (this.#index < this.#template.length) {
783
774
  const char_code = this.#template.charCodeAt(this.#index);
784
- if (this.#is_tag_name_char(char_code)) {
775
+ if (is_tag_name_char(char_code)) {
785
776
  this.#index++;
786
777
  } else {
787
778
  break;
@@ -907,34 +898,10 @@ export class MdzParser {
907
898
  };
908
899
  }
909
900
 
910
- /**
911
- * Extract a single tag (component or element) if it's the only non-whitespace content.
912
- * Returns the tag node if paragraph wrapping should be skipped (MDX convention),
913
- * or null if the content should be wrapped in a paragraph.
914
- */
915
- #extract_single_tag(nodes: Array<MdzNode>): MdzComponentNode | MdzElementNode | null {
916
- let tag: MdzComponentNode | MdzElementNode | null = null;
917
-
918
- for (const node of nodes) {
919
- if (node.type === 'Component' || node.type === 'Element') {
920
- if (tag) return null; // Multiple tags
921
- tag = node;
922
- } else if (node.type === 'Text') {
923
- // Allow only whitespace-only text nodes
924
- if (node.content.trim() !== '') return null;
925
- } else {
926
- // Any other node type means not a single tag
927
- return null;
928
- }
929
- }
930
-
931
- return tag;
932
- }
933
-
934
901
  /**
935
902
  * Read-only check if current position matches a block element.
936
903
  * Does not modify parser state — used to peek before flushing paragraph.
937
- * Returns which block type matched, or null if none.
904
+ * Caller must verify column-0 position before calling.
938
905
  */
939
906
  #peek_block_element(): 'heading' | 'hr' | 'codeblock' | null {
940
907
  if (this.#match_heading()) return 'heading';
@@ -976,54 +943,6 @@ export class MdzParser {
976
943
  this.#nodes.push(...state.nodes);
977
944
  }
978
945
 
979
- /**
980
- * Check if character code is a letter (A-Z, a-z).
981
- */
982
- #is_letter(char_code: number): boolean {
983
- return (
984
- (char_code >= A_UPPER && char_code <= Z_UPPER) ||
985
- (char_code >= A_LOWER && char_code <= Z_LOWER)
986
- );
987
- }
988
-
989
- /**
990
- * Check if character code is valid for tag name (letter, number, hyphen, underscore).
991
- */
992
- #is_tag_name_char(char_code: number): boolean {
993
- return (
994
- this.#is_letter(char_code) ||
995
- (char_code >= ZERO && char_code <= NINE) ||
996
- char_code === HYPHEN ||
997
- char_code === UNDERSCORE
998
- );
999
- }
1000
-
1001
- /**
1002
- * Check if character is part of a word for word boundary detection.
1003
- * Used to prevent intraword emphasis with `_` and `~` delimiters.
1004
- *
1005
- * Formatting delimiters (`*`, `_`, `~`) are NOT word characters - they're transparent.
1006
- * Only alphanumeric characters (A-Z, a-z, 0-9) are considered word characters.
1007
- *
1008
- * This prevents false positives with snake_case identifiers while allowing
1009
- * adjacent formatting like `**bold**_italic_`.
1010
- *
1011
- * @param char_code - Character code to check
1012
- */
1013
- #is_word_char(char_code: number): boolean {
1014
- // Formatting delimiters are never word chars (transparent for boundary checks)
1015
- if (char_code === ASTERISK || char_code === UNDERSCORE || char_code === TILDE) {
1016
- return false;
1017
- }
1018
-
1019
- // Alphanumeric characters are word chars for all delimiters
1020
- return (
1021
- (char_code >= A_UPPER && char_code <= Z_UPPER) ||
1022
- (char_code >= A_LOWER && char_code <= Z_LOWER) ||
1023
- (char_code >= ZERO && char_code <= NINE)
1024
- );
1025
- }
1026
-
1027
946
  /**
1028
947
  * Check if position is at a word boundary.
1029
948
  * Word boundary = not surrounded by word characters (A-Z, a-z, 0-9).
@@ -1037,7 +956,7 @@ export class MdzParser {
1037
956
  if (check_before && index > 0) {
1038
957
  const prev = this.#template.charCodeAt(index - 1);
1039
958
  // If preceded by word char, not at boundary
1040
- if (this.#is_word_char(prev)) {
959
+ if (is_word_char(prev)) {
1041
960
  return false;
1042
961
  }
1043
962
  }
@@ -1045,7 +964,7 @@ export class MdzParser {
1045
964
  if (check_after && index < this.#template.length) {
1046
965
  const next = this.#template.charCodeAt(index);
1047
966
  // If followed by word char, not at boundary
1048
- if (this.#is_word_char(next)) {
967
+ if (is_word_char(next)) {
1049
968
  return false;
1050
969
  }
1051
970
  }
@@ -1053,51 +972,6 @@ export class MdzParser {
1053
972
  return true;
1054
973
  }
1055
974
 
1056
- /**
1057
- * Check if character code is valid in URI path per RFC 3986.
1058
- * Validates against the `pchar` production plus path/query/fragment separators.
1059
- *
1060
- * Valid characters:
1061
- * - unreserved: A-Z a-z 0-9 - . _ ~
1062
- * - sub-delims: ! $ & ' ( ) * + , ; =
1063
- * - path allowed: : @
1064
- * - separators: / ? #
1065
- * - percent-encoding: %
1066
- */
1067
- #is_valid_path_char(char_code: number): boolean {
1068
- return (
1069
- (char_code >= A_UPPER && char_code <= Z_UPPER) ||
1070
- (char_code >= A_LOWER && char_code <= Z_LOWER) ||
1071
- (char_code >= ZERO && char_code <= NINE) ||
1072
- // unreserved: - . _ ~
1073
- char_code === HYPHEN ||
1074
- char_code === PERIOD ||
1075
- char_code === UNDERSCORE ||
1076
- char_code === TILDE ||
1077
- // sub-delims: ! $ & ' ( ) * + , ; =
1078
- char_code === EXCLAMATION ||
1079
- char_code === DOLLAR ||
1080
- char_code === AMPERSAND ||
1081
- char_code === APOSTROPHE ||
1082
- char_code === LEFT_PAREN ||
1083
- char_code === RIGHT_PAREN ||
1084
- char_code === ASTERISK ||
1085
- char_code === PLUS ||
1086
- char_code === COMMA ||
1087
- char_code === SEMICOLON ||
1088
- char_code === EQUALS ||
1089
- // path allowed: : @
1090
- char_code === COLON ||
1091
- char_code === AT ||
1092
- // separators: / ? #
1093
- char_code === SLASH ||
1094
- char_code === QUESTION ||
1095
- char_code === HASH ||
1096
- // percent-encoding: %
1097
- char_code === PERCENT
1098
- );
1099
- }
1100
-
1101
975
  /**
1102
976
  * Check if current position is the start of an external URL (`https://` or `http://`).
1103
977
  */
@@ -1123,31 +997,6 @@ export class MdzParser {
1123
997
  return false;
1124
998
  }
1125
999
 
1126
- /**
1127
- * Check if current position is the start of an internal path (starts with /).
1128
- */
1129
- #is_at_internal_path(): boolean {
1130
- if (this.#template.charCodeAt(this.#index) !== SLASH) {
1131
- return false;
1132
- }
1133
- // Check previous character - must be whitespace or start of string
1134
- // (to avoid matching / within relative paths like ./a/b or ../a/b)
1135
- if (this.#index > 0) {
1136
- const prev_char = this.#template.charCodeAt(this.#index - 1);
1137
- if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) {
1138
- return false;
1139
- }
1140
- }
1141
- // Must have at least one more character after /, and it must NOT be:
1142
- // - another / (to avoid matching // which is used for comments or protocol-relative URLs)
1143
- // - whitespace (a bare / followed by space is not a useful link)
1144
- if (this.#index + 1 >= this.#template.length) {
1145
- return false;
1146
- }
1147
- const next_char = this.#template.charCodeAt(this.#index + 1);
1148
- return next_char !== SLASH && next_char !== SPACE && next_char !== NEWLINE;
1149
- }
1150
-
1151
1000
  /**
1152
1001
  * Parse auto-detected external URL (`https://` or `http://`).
1153
1002
  * Uses RFC 3986 whitelist validation for valid URI characters.
@@ -1165,8 +1014,8 @@ export class MdzParser {
1165
1014
  // Collect URL characters using RFC 3986 whitelist
1166
1015
  // Stop at whitespace or any character invalid in URIs
1167
1016
  while (this.#index < this.#template.length) {
1168
- const char_code = this.#current_char();
1169
- if (char_code === SPACE || char_code === NEWLINE || !this.#is_valid_path_char(char_code)) {
1017
+ const char_code = this.#template.charCodeAt(this.#index);
1018
+ if (char_code === SPACE || char_code === NEWLINE || !is_valid_path_char(char_code)) {
1170
1019
  break;
1171
1020
  }
1172
1021
  this.#index++;
@@ -1175,7 +1024,7 @@ export class MdzParser {
1175
1024
  let reference = this.#template.slice(start, this.#index);
1176
1025
 
1177
1026
  // Apply GFM trailing punctuation trimming with balanced parentheses
1178
- reference = this.#trim_trailing_punctuation(reference);
1027
+ reference = trim_trailing_punctuation(reference);
1179
1028
 
1180
1029
  // Update index after trimming
1181
1030
  this.#index = start + reference.length;
@@ -1191,17 +1040,17 @@ export class MdzParser {
1191
1040
  }
1192
1041
 
1193
1042
  /**
1194
- * Parse auto-detected internal path (starts with /).
1043
+ * Parse auto-detected path (absolute `/`, relative `./` or `../`).
1195
1044
  * Uses RFC 3986 whitelist validation for valid URI characters.
1196
1045
  */
1197
- #parse_auto_link_internal(): MdzLinkNode {
1046
+ #parse_auto_link_path(): MdzLinkNode {
1198
1047
  const start = this.#index;
1199
1048
 
1200
1049
  // Collect path characters using RFC 3986 whitelist
1201
1050
  // Stop at whitespace or any character invalid in URIs
1202
1051
  while (this.#index < this.#template.length) {
1203
- const char_code = this.#current_char();
1204
- if (char_code === SPACE || char_code === NEWLINE || !this.#is_valid_path_char(char_code)) {
1052
+ const char_code = this.#template.charCodeAt(this.#index);
1053
+ if (char_code === SPACE || char_code === NEWLINE || !is_valid_path_char(char_code)) {
1205
1054
  break;
1206
1055
  }
1207
1056
  this.#index++;
@@ -1210,7 +1059,7 @@ export class MdzParser {
1210
1059
  let reference = this.#template.slice(start, this.#index);
1211
1060
 
1212
1061
  // Apply GFM trailing punctuation trimming
1213
- reference = this.#trim_trailing_punctuation(reference);
1062
+ reference = trim_trailing_punctuation(reference);
1214
1063
 
1215
1064
  // Update index after trimming
1216
1065
  this.#index = start + reference.length;
@@ -1225,60 +1074,6 @@ export class MdzParser {
1225
1074
  };
1226
1075
  }
1227
1076
 
1228
- /**
1229
- * Trim trailing punctuation from URL/path per RFC 3986 and GFM rules.
1230
- * - Trims simple trailing: .,;:!?]
1231
- * - Balanced logic for () only (valid in path components)
1232
- * - Invalid chars like [] {} are already stopped by whitelist, but ] trimmed as fallback
1233
- *
1234
- * Optimized to avoid O(n²) string slicing - tracks end index and slices once at the end.
1235
- */
1236
- #trim_trailing_punctuation(url: string): string {
1237
- let end = url.length;
1238
-
1239
- // Trim simple trailing punctuation (] as fallback - whitelist should prevent it)
1240
- while (end > 0) {
1241
- const last_char = url.charCodeAt(end - 1);
1242
- if (
1243
- last_char === PERIOD ||
1244
- last_char === COMMA ||
1245
- last_char === SEMICOLON ||
1246
- last_char === COLON ||
1247
- last_char === EXCLAMATION ||
1248
- last_char === QUESTION ||
1249
- last_char === RIGHT_BRACKET
1250
- ) {
1251
- end--;
1252
- } else {
1253
- break;
1254
- }
1255
- }
1256
-
1257
- // Handle balanced parentheses ONLY (parens are valid in URI path components)
1258
- // Count parentheses in the trimmed portion
1259
- let open_count = 0;
1260
- let close_count = 0;
1261
- for (let i = 0; i < end; i++) {
1262
- const char = url.charCodeAt(i);
1263
- if (char === LEFT_PAREN) open_count++;
1264
- if (char === RIGHT_PAREN) close_count++;
1265
- }
1266
-
1267
- // Trim unmatched trailing closing parens
1268
- while (end > 0 && close_count > open_count) {
1269
- const last_char = url.charCodeAt(end - 1);
1270
- if (last_char === RIGHT_PAREN) {
1271
- end--;
1272
- close_count--;
1273
- } else {
1274
- break;
1275
- }
1276
- }
1277
-
1278
- // Return original string if no trimming, otherwise slice once
1279
- return end === url.length ? url : url.slice(0, end);
1280
- }
1281
-
1282
1077
  /**
1283
1078
  * Parse plain text until special character encountered.
1284
1079
  * Preserves all whitespace (except paragraph breaks handled separately).
@@ -1287,16 +1082,19 @@ export class MdzParser {
1287
1082
  #parse_text(): MdzTextNode | MdzLinkNode {
1288
1083
  const start = this.#index;
1289
1084
 
1290
- // Check for URL or internal path at current position
1085
+ // Check for URL or internal absolute/relative path at current position
1291
1086
  if (this.#is_at_url()) {
1292
1087
  return this.#parse_auto_link_url();
1293
1088
  }
1294
- if (this.#is_at_internal_path()) {
1295
- return this.#parse_auto_link_internal();
1089
+ if (
1090
+ is_at_absolute_path(this.#template, this.#index) ||
1091
+ is_at_relative_path(this.#template, this.#index)
1092
+ ) {
1093
+ return this.#parse_auto_link_path();
1296
1094
  }
1297
1095
 
1298
1096
  while (this.#index < this.#template.length) {
1299
- const char_code = this.#current_char();
1097
+ const char_code = this.#template.charCodeAt(this.#index);
1300
1098
 
1301
1099
  // Stop at special characters (but preserve single newlines)
1302
1100
  if (
@@ -1332,8 +1130,12 @@ export class MdzParser {
1332
1130
  }
1333
1131
  }
1334
1132
 
1335
- // Check for URL or internal path mid-text
1336
- if (this.#is_at_url() || this.#is_at_internal_path()) {
1133
+ // Check for URL or internal absolute/relative path mid-text (char code guard avoids startsWith on every char)
1134
+ if (
1135
+ (char_code === 104 /* h */ && this.#is_at_url()) ||
1136
+ (char_code === SLASH && is_at_absolute_path(this.#template, this.#index)) ||
1137
+ (char_code === PERIOD && is_at_relative_path(this.#template, this.#index))
1138
+ ) {
1337
1139
  break;
1338
1140
  }
1339
1141
 
@@ -1404,20 +1206,13 @@ export class MdzParser {
1404
1206
  return nodes;
1405
1207
  }
1406
1208
 
1407
- /**
1408
- * Get character code at current index, or -1 if at EOF.
1409
- */
1410
- #current_char(): number {
1411
- return this.#index < this.#template.length ? this.#template.charCodeAt(this.#index) : -1;
1412
- }
1413
-
1414
1209
  /**
1415
1210
  * Check if current position is at a paragraph break (double newline).
1416
1211
  */
1417
1212
  #is_at_paragraph_break(): boolean {
1418
1213
  return (
1419
- this.#current_char() === NEWLINE &&
1420
1214
  this.#index + 1 < this.#template.length &&
1215
+ this.#template.charCodeAt(this.#index) === NEWLINE &&
1421
1216
  this.#template.charCodeAt(this.#index + 1) === NEWLINE
1422
1217
  );
1423
1218
  }
@@ -1447,11 +1242,6 @@ export class MdzParser {
1447
1242
  #match_hr(): boolean {
1448
1243
  let i = this.#index;
1449
1244
 
1450
- // Must start at column 0 (beginning of input or after newline)
1451
- if (i > 0 && this.#template.charCodeAt(i - 1) !== NEWLINE) {
1452
- return false;
1453
- }
1454
-
1455
1245
  // Must have exactly three hyphens
1456
1246
  if (
1457
1247
  i + HR_HYPHEN_COUNT > this.#template.length ||
@@ -1514,11 +1304,6 @@ export class MdzParser {
1514
1304
  #match_heading(): boolean {
1515
1305
  let i = this.#index;
1516
1306
 
1517
- // Must start at column 0 (beginning of input or after newline)
1518
- if (i > 0 && this.#template.charCodeAt(i - 1) !== NEWLINE) {
1519
- return false;
1520
- }
1521
-
1522
1307
  // Count hashes (must be 1-6)
1523
1308
  let hash_count = 0;
1524
1309
  while (
@@ -1540,22 +1325,16 @@ export class MdzParser {
1540
1325
  }
1541
1326
  i++; // consume the space
1542
1327
 
1543
- // Must have non-whitespace content after the space (not just whitespace until newline)
1544
- let has_content = false;
1545
- while (i < this.#template.length && this.#template.charCodeAt(i) !== NEWLINE) {
1328
+ // Must have at least one non-whitespace character after the space
1329
+ while (i < this.#template.length) {
1546
1330
  const char_code = this.#template.charCodeAt(i);
1547
- if (char_code !== SPACE && char_code !== TAB) {
1548
- has_content = true;
1549
- }
1331
+ if (char_code === NEWLINE) return false; // reached end of line with only whitespace
1332
+ if (char_code !== SPACE && char_code !== TAB) return true;
1550
1333
  i++;
1551
1334
  }
1552
1335
 
1553
- if (!has_content) {
1554
- return false; // heading with only whitespace, treat as plain text
1555
- }
1556
-
1557
- // At newline or EOF — both are valid
1558
- return true;
1336
+ // Reached EOF with only whitespace after hashes
1337
+ return false;
1559
1338
  }
1560
1339
 
1561
1340
  /**
@@ -1631,11 +1410,6 @@ export class MdzParser {
1631
1410
  #match_code_block(): boolean {
1632
1411
  let i = this.#index;
1633
1412
 
1634
- // Must start at column 0 (beginning of input or after newline)
1635
- if (i > 0 && this.#template.charCodeAt(i - 1) !== NEWLINE) {
1636
- return false;
1637
- }
1638
-
1639
1413
  // Must have at least three backticks
1640
1414
  let backtick_count = 0;
1641
1415
  while (i < this.#template.length && this.#template.charCodeAt(i) === BACKTICK) {
@@ -1821,3 +1595,33 @@ export class MdzParser {
1821
1595
  */
1822
1596
  const URL_PATTERN = /^https?:\/\/[^\s)\]}<>.,:/?#!]/;
1823
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
+ };
@@ -32,3 +32,12 @@ export const mdz_components_context = create_context<MdzComponents>();
32
32
  * By default, no HTML elements are allowed.
33
33
  */
34
34
  export const mdz_elements_context = create_context<MdzElements>();
35
+
36
+ /**
37
+ * Context for providing a base path getter for resolving relative links in mdz content.
38
+ * Set to a getter (e.g., `() => base`) so changes to the base prop are reflected
39
+ * without needing an effect. When the getter returns a path like `'/docs/mdz/'`,
40
+ * relative paths like `./grammar` resolve to `/docs/mdz/grammar` before rendering.
41
+ * When not set, relative paths use raw hrefs (browser resolves them).
42
+ */
43
+ export const mdz_base_context = create_context<() => string | undefined>();