sommark 4.0.3 → 4.2.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 (43) hide show
  1. package/README.md +304 -73
  2. package/cli/cli.mjs +1 -1
  3. package/cli/commands/build.js +3 -1
  4. package/cli/commands/help.js +2 -0
  5. package/cli/commands/init.js +25 -6
  6. package/cli/constants.js +2 -1
  7. package/cli/helpers/transpile.js +5 -2
  8. package/constants/html_props.js +1 -0
  9. package/core/evaluator.js +1061 -0
  10. package/core/formats.js +15 -7
  11. package/core/helpers/config-loader.js +16 -8
  12. package/core/helpers/lib.js +72 -0
  13. package/core/helpers/preprocessor.js +202 -0
  14. package/core/helpers/runtimeOutput.js +28 -0
  15. package/core/helpers/url.js +12 -0
  16. package/core/labels.js +9 -2
  17. package/core/lexer.js +228 -61
  18. package/core/modules.js +338 -60
  19. package/core/parser.js +275 -55
  20. package/core/tokenTypes.js +11 -0
  21. package/core/transpiler.js +352 -66
  22. package/core/validator.js +70 -7
  23. package/formatter/tag.js +31 -7
  24. package/grammar.ebnf +21 -10
  25. package/helpers/fetch-fs.js +37 -0
  26. package/helpers/safeDataParser.js +3 -3
  27. package/helpers/spinner.js +97 -0
  28. package/helpers/utils.js +46 -0
  29. package/helpers/virtual-fs.js +29 -0
  30. package/index.browser.js +87 -0
  31. package/index.js +23 -332
  32. package/index.shared.js +443 -0
  33. package/mappers/languages/html.js +50 -9
  34. package/mappers/languages/json.js +81 -38
  35. package/mappers/languages/jsonc.js +82 -0
  36. package/mappers/languages/markdown.js +88 -48
  37. package/mappers/languages/mdx.js +50 -15
  38. package/mappers/languages/text.js +67 -0
  39. package/mappers/languages/xml.js +6 -6
  40. package/mappers/mapper.js +36 -4
  41. package/mappers/shared/index.js +12 -13
  42. package/package.json +11 -2
  43. package/core/formatter.js +0 -215
package/core/validator.js CHANGED
@@ -18,11 +18,40 @@ import { transpilerError } from "./errors.js";
18
18
  const runValidations = (node, target, instance) => {
19
19
  if (!target || !target.options) return;
20
20
  const rules = target.options.rules || {};
21
- const id = (target.id) ? (Array.isArray(target.id) ? target.id.join(" | ") : target.id) : "Unknown";
22
- const context = instance ? { src: instance.src, range: node.range, filename: instance.filename } : null;
21
+ const id = (target.id) ? (Array.isArray(target.id) ? target.id.join(" | ") : target.id) : (node.id || "Unknown");
22
+ const errorRange = node.range ? {
23
+ start: node.range.start,
24
+ end: {
25
+ line: node.range.start.line,
26
+ character: node.range.start.character + (node.id || "").length + 2
27
+ }
28
+ } : null;
29
+ const context = instance ? { src: instance.src, range: errorRange, filename: instance.filename } : null;
30
+
31
+ // -- Structural Integrity (Empty Body / Self-Closing) ----------------- //
32
+ const isEmptyBodyTarget = rules.is_empty_body || rules.is_self_closing;
33
+
34
+ // -- Node Type Validation --------------------------------------------- //
35
+ if (target.options.type) {
36
+ const allowedTypes = Array.isArray(target.options.type) ? target.options.type : [target.options.type];
37
+ const hasAny = allowedTypes.includes("any");
38
+ if (!hasAny && !allowedTypes.includes(node.structure)) {
39
+ const isReserved = ["import", "$use-module", "slot", "for-each"].includes(id.toLowerCase());
40
+ const msg = isReserved
41
+ ? `<$yellow:Reserved keyword$> <$blue:'${id}'$> <$yellow:is strictly defined as a [${allowedTypes.join(", ")}] structure node, but was used as a [${node.structure}] structure node.$>`
42
+ : `<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:is defined as type(s) [${allowedTypes.join(", ")}], but was used as a [${node.structure}] structure node.$>`;
43
+
44
+ transpilerError(
45
+ [
46
+ "{N}",
47
+ msg
48
+ ],
49
+ context
50
+ );
51
+ }
52
+ }
23
53
 
24
- // -- Structural Integrity (Self-Closing) ------------------------------ //
25
- if (rules.is_self_closing && node.type === "Block" && node.body) {
54
+ if (isEmptyBodyTarget && node.type === "Block" && !node.isSelfClosing && node.body) {
26
55
  const hasContent = node.body.some(child => {
27
56
  if (child.type === "Text") {
28
57
  return (child.text || "").trim().length > 0;
@@ -33,8 +62,32 @@ const runValidations = (node, target, instance) => {
33
62
  if (hasContent) {
34
63
  transpilerError(
35
64
  [
36
- "<$red:Validation Error:$> ",
37
- `<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:is self-closing and cannot have children (body).$>`
65
+ "{N}",
66
+ "<$red:[Validation Error]:$>{N}",
67
+ `<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:is defined as an empty-body component and cannot have children.$>`
68
+ ],
69
+ context
70
+ );
71
+ }
72
+ }
73
+
74
+ // -- Arguments Validation (Required Args) ----------------------------- //
75
+ const isStructural = node.type === "Block" || node.type === "AtBlock";
76
+ if (isStructural && rules.required_args && Array.isArray(rules.required_args)) {
77
+ const missingArgs = rules.required_args.filter(arg => {
78
+ // Check if the argument exists in named args or as a positional arg (if arg is a number)
79
+ if (typeof arg === "number") {
80
+ return node.args[arg] === undefined;
81
+ }
82
+ return node.args[arg] === undefined;
83
+ });
84
+
85
+ if (missingArgs.length > 0) {
86
+ transpilerError(
87
+ [
88
+ "{N}",
89
+ `<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:is missing required arguments:$> <$red:${missingArgs.join(", ")}$>{N}`,
90
+ `<$blue:Please ensure these arguments are provided in the template usage.$>`
38
91
  ],
39
92
  context
40
93
  );
@@ -69,7 +122,17 @@ export function validateAST(ast, mapperFile, instance) {
69
122
 
70
123
  // 1. Identify Target
71
124
  if (node.id) {
72
- const target = mapperFile.get(node.id) || (mapperFile.getUnknownTag ? mapperFile.getUnknownTag(node) : null);
125
+ let target = null;
126
+ const lowerId = node.id.toLowerCase();
127
+ if (["import", "$use-module", "slot", "for-each"].includes(lowerId)) {
128
+ target = {
129
+ id: lowerId,
130
+ options: { type: "Block" }
131
+ };
132
+ } else {
133
+ target = mapperFile.get(node.id) || (mapperFile.getUnknownTag ? mapperFile.getUnknownTag(node) : null);
134
+ }
135
+
73
136
  if (target) {
74
137
  runValidations(node, target, instance);
75
138
  }
package/formatter/tag.js CHANGED
@@ -69,6 +69,8 @@ class TagBuilder {
69
69
  const id = this.tagName.toLowerCase();
70
70
  const isCodeStyleOrScript = ["style", "script"].includes(id);
71
71
  let inline_style = "";
72
+ const useClassFallback = options.fallbackTarget === "class";
73
+ const classSet = new Set();
72
74
 
73
75
  // 1. Initial CSS Variable/Style processing
74
76
  if (!isCodeStyleOrScript && args.style) {
@@ -83,12 +85,20 @@ class TagBuilder {
83
85
  }
84
86
  }
85
87
 
86
- // 2. Attribute Dispatching
88
+ // 2. Pre-collect native classes if using class fallback
89
+ if (useClassFallback) {
90
+ if (args.class) {
91
+ String(args.class).split(/\s+/).filter(Boolean).forEach(c => classSet.add(c));
92
+ }
93
+ }
94
+
95
+ // 3. Attribute Dispatching
87
96
  const keys = Object.keys(args).filter(arg => isNaN(parseInt(arg)));
88
97
  keys.forEach(key => {
89
98
  if (!isNaN(parseInt(key))) return; // Skip numeric positional arguments
90
99
  if (key === "style") return;
91
100
  if (isCodeStyleOrScript && key === "scoped") return;
101
+ if (useClassFallback && key === "class") return;
92
102
 
93
103
  const isDimensionAttributeSupported = ["img", "video", "svg", "canvas", "iframe", "object", "embed"].includes(id);
94
104
  const isWidthOrHeight = key === "width" || key === "height";
@@ -99,21 +109,35 @@ class TagBuilder {
99
109
 
100
110
  const k = isEvent ? key.toLowerCase() : (isNative || isCustom) ? key : kebabize(key);
101
111
 
102
- if (isCodeStyleOrScript) {
103
- // Specialized tags: only render standard attributes, no styling fallback
104
- this.#attr.push(`${k}="${escapeHTML(String(args[key]))}"`);
112
+ if (isCodeStyleOrScript || options.fallbackTarget === false) {
113
+ // Specialized tags or fallback disabled: render standard attributes, no styling fallback
114
+ const val = typeof args[key] === "object" ? JSON.stringify(args[key]) : args[key];
115
+ this.#attr.push(`${k}="${escapeHTML(String(val))}"`);
105
116
  } else {
106
- // Standard elements: process smart styling fallbacks for non-native props
117
+ // Standard elements: process smart fallbacks
107
118
  if (isEvent || ((isNative || isCustom) && (!isWidthOrHeight || isDimensionAttributeSupported)) || isDataOrAria) {
108
119
  const val = typeof args[key] === "object" ? JSON.stringify(args[key]) : args[key];
109
120
  this.#attr.push(`${k}="${escapeHTML(String(val))}"`);
110
121
  } else {
111
- const val = typeof args[key] === "object" ? JSON.stringify(args[key]) : args[key];
112
- inline_style += `${k}:${val};`;
122
+ if (useClassFallback) {
123
+ const val = args[key];
124
+ if (val === true || val === "true") {
125
+ classSet.add(k);
126
+ } else if (val !== false && val !== "false" && val !== null && val !== undefined) {
127
+ classSet.add(`${k}-${val}`);
128
+ }
129
+ } else {
130
+ const val = typeof args[key] === "object" ? JSON.stringify(args[key]) : args[key];
131
+ inline_style += `${k}:${val};`;
132
+ }
113
133
  }
114
134
  }
115
135
  });
116
136
 
137
+ if (useClassFallback && classSet.size > 0) {
138
+ this.#attr.push(`class="${escapeHTML([...classSet].join(" "))}"`);
139
+ }
140
+
117
141
  if (inline_style) {
118
142
  // V4 DYNAMIC CSS: Automatically wrap CSS variables in var()
119
143
  const processedStyle = inline_style.replace(/(^|[^\w\-_$])(--[\w\-_$]+)(?![\w\-_$]|:)/g, "$1var($2)");
package/grammar.ebnf CHANGED
@@ -18,18 +18,28 @@ EscapeChar = "\", ? any character except WhiteSpace ?;
18
18
 
19
19
  (* "Junk" refers to whitespace and comments which the parser skips in headers *)
20
20
  Comment = "#", { ? any character except "\n" ? }, "\n";
21
- Junk = { WhiteSpace | Comment };
21
+ CommentBlock = "###", { ? any character ? }, "###";
22
+ Junk = { WhiteSpace | Comment | CommentBlock };
22
23
 
23
24
  (* Prefix Layers for Dynamic Data *)
24
25
  PrefixJS = "js{", { ? any character ? }, "}";
25
26
  PrefixP = "p{", { ? any character ? }, "}";
26
- PrefixLayer = PrefixJS | PrefixP;
27
+ PrefixV = "v{", { ? any character ? }, "}";
28
+ PrefixLayer = PrefixJS | PrefixP | PrefixV;
29
+
30
+ (* Static / Runtime Logic Blocks *)
31
+ LogicBlock = ( "static" | "runtime" ), [ " " ], "${", { ? any character ? }, "}$";
27
32
 
28
33
  (* ========================================== *)
29
34
  (* Block Syntax (Containers) *)
30
35
  (* ========================================== *)
31
36
 
32
- Block = BlockOpen, BlockBody, BlockEnd;
37
+ Block = StandardBlock | SelfClosingBlock;
38
+
39
+ StandardBlock = BlockOpen, BlockBody, BlockEnd;
40
+
41
+ (* Self-Closing Blocks end with an exclamation mark (!) *)
42
+ SelfClosingBlock = "[", Junk, BlockID, Junk, [ "=", Junk, BlockArgs, Junk ], "!", Junk, "]";
33
43
 
34
44
  (* Flexible Header with Junk-Skipping. Blocks allow colons in ID. *)
35
45
  BlockOpen = "[", Junk, BlockID, Junk, [ "=", Junk, BlockArgs, Junk ], "]";
@@ -41,7 +51,7 @@ BlockArgs = BlockArg, { Junk, ",", Junk, BlockArg };
41
51
  BlockArg = [ BlockKey, Junk, ":", Junk ], Value;
42
52
 
43
53
  BlockKey = BlockID | QuotedString;
44
- Value = QuotedString | PrefixLayer | RawValue;
54
+ Value = QuotedString | PrefixLayer | LogicBlock | RawValue;
45
55
 
46
56
  QuotedString = ("'" | '"'), { ? any character ? | EscapeChar }, ("'" | '"');
47
57
  RawValue = { ? any char except ",", Junk, "]" ? | EscapeChar };
@@ -50,15 +60,16 @@ RawValue = { ? any char except ",", Junk, "]" ? | EscapeChar };
50
60
  (* Inline Statement Syntax *)
51
61
  (* ========================================== *)
52
62
 
53
- (* Inlines use SimpleID (NO COLONS allowed in ID) *)
54
- InlineStatement = "(", InlineContent, ")", Junk, "->", Junk, "(", SimpleID, [ Junk, ":", Junk, InlineArgs ], ")";
63
+ (* Inlines use SimpleID (NO COLONS allowed in ID unless key-value) *)
64
+ InlineStatement = "(", InlineContent, ")", Junk, "->", Junk, "(", SimpleID, [ Junk, ( "=" | ":" ), Junk, InlineArgs ], ")";
55
65
 
56
66
  (* Content allows balanced parentheses and placeholders *)
57
- InlineContent = { ? any char except ")" ? | BalancedParen | EscapeChar | PrefixP };
67
+ InlineContent = { ? any char except ")" ? | BalancedParen | EscapeChar | PrefixP | PrefixV };
58
68
  BalancedParen = "(", InlineContent, ")";
59
69
 
60
- (* Inline Arguments: Positional Only *)
61
- InlineArgs = Value, { Junk, ",", Junk, Value };
70
+ (* Inline Arguments: Positional and Named support *)
71
+ InlineArgs = InlineArg, { Junk, ",", Junk, InlineArg };
72
+ InlineArg = [ SimpleID, Junk, ":", Junk ], Value;
62
73
 
63
74
  (* ========================================== *)
64
75
  (* At-Block Syntax (Raw Containers) *)
@@ -83,4 +94,4 @@ AtBody = { ? any character ? | EscapeChar };
83
94
  Document = { Block | InlineStatement | AtBlock | TextContent | Comment | WhiteSpace };
84
95
 
85
96
  (* Text Content: Plain text with fallback behavior for symbols *)
86
- TextContent = { ? any char except "[", "@_", "(", "#" ? | EscapeChar | PrefixP };
97
+ TextContent = { ? any char except "[", "@_", "(", "#" ? | EscapeChar | PrefixP | PrefixV | LogicBlock };
@@ -0,0 +1,37 @@
1
+ export class FetchFS {
2
+ constructor(baseURL) {
3
+ this.baseURL = baseURL.endsWith("/") ? baseURL : baseURL + "/";
4
+ this._cache = new Map();
5
+ }
6
+
7
+ _resolve(p) {
8
+ if (p.startsWith("http://") || p.startsWith("https://")) return p;
9
+ return this.baseURL + p.replace(/^\//, "");
10
+ }
11
+
12
+ async readFile(p, encoding) {
13
+ const url = this._resolve(p);
14
+ if (this._cache.has(url)) return this._cache.get(url);
15
+ const res = await fetch(url);
16
+ if (!res.ok) throw new Error(`File not found: ${p} (${res.status})`);
17
+ const text = await res.text();
18
+ this._cache.set(url, text);
19
+ return text;
20
+ }
21
+
22
+ async exists(p) {
23
+ try { await this.readFile(p); return true; }
24
+ catch { return false; }
25
+ }
26
+
27
+ // Sync versions — backed by cache, only valid after readFile has been called for that path
28
+ existsSync(p) {
29
+ return this._cache.has(this._resolve(p));
30
+ }
31
+
32
+ readFileSync(p, encoding) {
33
+ const cached = this._cache.get(this._resolve(p));
34
+ if (cached !== undefined) return cached;
35
+ throw new Error(`Not in cache: ${p}. Call readFile first.`);
36
+ }
37
+ }
@@ -28,7 +28,7 @@ export function safeDataParse(str) {
28
28
  if (char === '{') return parseObject();
29
29
  if (char === '[') return parseArray();
30
30
  if (char === '"' || char === "'") return parseString();
31
-
31
+
32
32
  // Primitives or Unquoted identifiers
33
33
  return parsePrimitiveOrIdentifier();
34
34
  }
@@ -97,12 +97,12 @@ export function safeDataParse(str) {
97
97
  index++;
98
98
  }
99
99
  const token = s.slice(start, index);
100
-
100
+
101
101
  if (token === "true") return true;
102
102
  if (token === "false") return false;
103
103
  if (token === "null") return null;
104
104
  if (!isNaN(Number(token))) return Number(token);
105
-
105
+
106
106
  return token; // Fallback to string if it looks like an identifier
107
107
  }
108
108
 
@@ -0,0 +1,97 @@
1
+ // Premium, dependency-free interactive terminal spinner for compilation feedback
2
+ const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3
+ let spinnerIndex = 0;
4
+ let activeSpinner = null;
5
+ let spinnerDepth = 0;
6
+
7
+ let originalStdoutWrite = null;
8
+ let originalStderrWrite = null;
9
+ let redrawTimeout = null;
10
+
11
+ export function startSpinner() {
12
+ if (typeof process === "undefined" || !process.stdout?.isTTY) {
13
+ spinnerDepth++;
14
+ return;
15
+ }
16
+ if (!activeSpinner) {
17
+ // Hide terminal cursor for a clean premium visual feel
18
+ process.stdout.write("\x1b[?25l");
19
+
20
+ // Print the first frame immediately
21
+ const frame = spinnerFrames[spinnerIndex];
22
+ process.stdout.write(`\r\x1b[38;5;39m${frame}\x1b[0m \x1b[1mSomMark:\x1b[0m \x1b[38;5;208mCompiling template...\x1b[0m`);
23
+ spinnerIndex = (spinnerIndex + 1) % spinnerFrames.length;
24
+
25
+ // Intercept stdout and stderr writes to keep the spinner on the very bottom line
26
+ originalStdoutWrite = process.stdout.write;
27
+ process.stdout.write = function(chunk, encoding, callback) {
28
+ originalStdoutWrite.call(process.stdout, "\r\x1b[K");
29
+ const res = originalStdoutWrite.call(process.stdout, chunk, encoding, callback);
30
+ if (activeSpinner) {
31
+ if (redrawTimeout) clearTimeout(redrawTimeout);
32
+ redrawTimeout = setTimeout(() => {
33
+ if (activeSpinner && originalStdoutWrite) {
34
+ const f = spinnerFrames[spinnerIndex];
35
+ originalStdoutWrite.call(process.stdout, `\r\x1b[38;5;39m${f}\x1b[0m \x1b[1mSomMark:\x1b[0m \x1b[38;5;208mCompiling template...\x1b[0m`);
36
+ }
37
+ }, 0);
38
+ }
39
+ return res;
40
+ };
41
+
42
+ originalStderrWrite = process.stderr.write;
43
+ process.stderr.write = function(chunk, encoding, callback) {
44
+ if (originalStdoutWrite) {
45
+ originalStdoutWrite.call(process.stdout, "\r\x1b[K");
46
+ }
47
+ const res = originalStderrWrite.call(process.stderr, chunk, encoding, callback);
48
+ if (activeSpinner) {
49
+ if (redrawTimeout) clearTimeout(redrawTimeout);
50
+ redrawTimeout = setTimeout(() => {
51
+ if (activeSpinner && originalStdoutWrite) {
52
+ const f = spinnerFrames[spinnerIndex];
53
+ originalStdoutWrite.call(process.stdout, `\r\x1b[38;5;39m${f}\x1b[0m \x1b[1mSomMark:\x1b[0m \x1b[38;5;208mCompiling template...\x1b[0m`);
54
+ }
55
+ }, 0);
56
+ }
57
+ return res;
58
+ };
59
+
60
+ activeSpinner = setInterval(() => {
61
+ const frame = spinnerFrames[spinnerIndex];
62
+ if (originalStdoutWrite) {
63
+ originalStdoutWrite.call(process.stdout, `\r\x1b[38;5;39m${frame}\x1b[0m \x1b[1mSomMark:\x1b[0m \x1b[38;5;208mCompiling template...\x1b[0m`);
64
+ } else {
65
+ process.stdout.write(`\r\x1b[38;5;39m${frame}\x1b[0m \x1b[1mSomMark:\x1b[0m \x1b[38;5;208mCompiling template...\x1b[0m`);
66
+ }
67
+ spinnerIndex = (spinnerIndex + 1) % spinnerFrames.length;
68
+ }, 80);
69
+ }
70
+ spinnerDepth++;
71
+ }
72
+
73
+ export function stopSpinner() {
74
+ spinnerDepth--;
75
+ if (spinnerDepth <= 0) {
76
+ spinnerDepth = 0;
77
+ if (activeSpinner) {
78
+ clearInterval(activeSpinner);
79
+ activeSpinner = null;
80
+
81
+ // Restore original writes
82
+ if (originalStdoutWrite) {
83
+ process.stdout.write = originalStdoutWrite;
84
+ originalStdoutWrite = null;
85
+ }
86
+ if (originalStderrWrite) {
87
+ process.stderr.write = originalStderrWrite;
88
+ originalStderrWrite = null;
89
+ }
90
+
91
+ if (typeof process !== "undefined" && process.stdout) {
92
+ // Clear the spinner line and restore the terminal cursor
93
+ process.stdout.write("\r\x1b[K\x1b[?25h");
94
+ }
95
+ }
96
+ }
97
+ }
package/helpers/utils.js CHANGED
@@ -168,3 +168,49 @@ export function levenshtein(a, b) {
168
168
  }
169
169
  return matrix[b.length][a.length];
170
170
  }
171
+
172
+ // -- Unresolved Placeholder Helpers ---------------------------------------- //
173
+
174
+ export const UNRESOLVED_PREFIX = "SOMMARK_UNRESOLVED";
175
+ export const UNRESOLVED_SUFFIX = "SOMMARK";
176
+
177
+ /**
178
+ * Official method to get the unique envelope for an unresolved prefix value.
179
+ * @param {string} prefix - The layer ('p' or 'v').
180
+ * @param {string} expectedValue - The placeholder key.
181
+ * @returns {string} - The unique envelope string.
182
+ */
183
+ export function getPrefixValue(prefix, expectedValue) {
184
+ if (!prefix || (prefix !== "p" && prefix !== "v")) {
185
+ sommarkError([
186
+ `<$red:getPrefixValue Error:$> {N}`,
187
+ `<$yellow:prefix must be 'p' or 'v'. Received:$> <$cyan:'${prefix}'$>`
188
+ ]);
189
+ }
190
+
191
+ if (!expectedValue || typeof expectedValue !== "string" || expectedValue.trim() === "") {
192
+ sommarkError([
193
+ `<$red:getPrefixValue Error:$> {N}`,
194
+ `<$yellow:expectedValue must be a non-empty string. Received:$> <$cyan:'${expectedValue}'$>`
195
+ ]);
196
+ }
197
+
198
+ return `${UNRESOLVED_PREFIX}_${prefix}_${expectedValue}_${UNRESOLVED_SUFFIX}`;
199
+ }
200
+
201
+ /**
202
+ * Performs a final pass on a string to clean up any remaining unresolved envelopes.
203
+ * @param {string} content - The content to clean.
204
+ * @param {string|null} [fallback] - The format to use for missing keys (e.g., "$layer{$key}" or "").
205
+ * @returns {string} - The cleaned content.
206
+ */
207
+ export function cleanUnresolved(content, fallback = null) {
208
+ const pattern = new RegExp(`${UNRESOLVED_PREFIX}_(p|v)_(.+?)_${UNRESOLVED_SUFFIX}`, "g");
209
+ return content.replace(pattern, (match, layer, key) => {
210
+ if (fallback !== null) {
211
+ return fallback.replace("$layer", layer).replace("$key", key);
212
+ }
213
+ // Default: Keep as is or restore classic
214
+ return `${layer}{${key}}`;
215
+ });
216
+ }
@@ -0,0 +1,29 @@
1
+ import path from "pathe";
2
+
3
+ /**
4
+ * Avoids any dependency on Node.js built-in modules (like buffer, path, stream).
5
+ */
6
+ export class VirtualFS {
7
+ constructor(files = {}) {
8
+ this.files = {};
9
+ for (const [key, value] of Object.entries(files)) {
10
+ this.files[path.normalize(key)] = value;
11
+ }
12
+ }
13
+
14
+ existsSync(p) {
15
+ const normalized = path.normalize(p);
16
+ return normalized in this.files;
17
+ }
18
+
19
+ readFileSync(p, encoding) {
20
+ const normalized = path.normalize(p);
21
+ if (normalized in this.files) {
22
+ return this.files[normalized];
23
+ }
24
+ throw new Error(`File not found: ${p}`);
25
+ }
26
+
27
+ async exists(p) { return this.existsSync(p); }
28
+ async readFile(p, encoding) { return this.readFileSync(p, encoding); }
29
+ }
@@ -0,0 +1,87 @@
1
+ import SomMark, { setDefaultFs, setDefaultCwd } from "./index.shared.js";
2
+ export * from "./index.shared.js";
3
+
4
+ setDefaultFs(null);
5
+ setDefaultCwd("/");
6
+
7
+ /**
8
+ * Resolves a relative path into a full URL using the current document location.
9
+ * Use this to set `baseDir` when loading .smark modules via fetch in the browser.
10
+ *
11
+ * @param {string} relativePath - Path relative to the HTML document (e.g. "./templates/").
12
+ * @returns {string} Absolute URL string suitable for use as `baseDir`.
13
+ *
14
+ * @example
15
+ * import SomMark, { resolveBaseDir } from "sommark/browser";
16
+ * const engine = new SomMark({ src, format: "html", baseDir: resolveBaseDir("./templates/") });
17
+ */
18
+ export function resolveBaseDir(relativePath = "./") {
19
+ if (typeof document === "undefined") {
20
+ throw new Error(
21
+ "[SomMark] resolveBaseDir() can only be called in a browser environment.\n" +
22
+ "In Node.js, pass a file path directly as 'baseDir' instead."
23
+ );
24
+ }
25
+
26
+ if (typeof relativePath !== "string" || relativePath.trim() === "") {
27
+ throw new Error(
28
+ "[SomMark] resolveBaseDir() expects a non-empty string path, " +
29
+ `but received: ${JSON.stringify(relativePath)}`
30
+ );
31
+ }
32
+
33
+ try {
34
+ return new URL(relativePath, document.baseURI).href;
35
+ } catch (err) {
36
+ throw new Error(
37
+ `[SomMark] resolveBaseDir() could not resolve path '${relativePath}' ` +
38
+ `against document URL '${document.baseURI}'.\n${err.message}`
39
+ );
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Injects compiled HTML into a container and activates any <script> tags inside it.
45
+ * Browsers intentionally skip scripts inserted via innerHTML — this re-creates each
46
+ * one as a live DOM element so they execute normally.
47
+ *
48
+ * @param {HTMLElement} container - The element to render into.
49
+ * @param {string} html - The compiled HTML string.
50
+ *
51
+ * @example
52
+ * import SomMark, { renderCompiledHTML } from "sommark/browser";
53
+ * const html = await new SomMark({ src, format: "html" }).transpile();
54
+ * renderCompiledHTML(document.getElementById("output"), html);
55
+ */
56
+ export function renderCompiledHTML(container, html) {
57
+ if (typeof document === "undefined") {
58
+ throw new Error(
59
+ "[SomMark] renderCompiledHTML() can only be called in a browser environment."
60
+ );
61
+ }
62
+ if (!(container instanceof HTMLElement)) {
63
+ throw new TypeError(
64
+ "[SomMark] renderCompiledHTML() expects an HTMLElement as the first argument, " +
65
+ `but received: ${Object.prototype.toString.call(container)}`
66
+ );
67
+ }
68
+ if (typeof html !== "string") {
69
+ throw new TypeError(
70
+ "[SomMark] renderCompiledHTML() expects a string as the second argument, " +
71
+ `but received: ${Object.prototype.toString.call(html)}`
72
+ );
73
+ }
74
+
75
+ container.innerHTML = html;
76
+
77
+ for (const inertScript of container.querySelectorAll("script")) {
78
+ const liveScript = document.createElement("script");
79
+ for (const { name, value } of inertScript.attributes) {
80
+ liveScript.setAttribute(name, value);
81
+ }
82
+ liveScript.textContent = inertScript.textContent;
83
+ inertScript.replaceWith(liveScript);
84
+ }
85
+ }
86
+
87
+ export default SomMark;