sommark 4.1.0 → 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.
@@ -1,4 +1,4 @@
1
- import path from "node:path";
1
+ import path from "pathe";
2
2
  import fs from "node:fs/promises";
3
3
  import { pathToFileURL } from "node:url";
4
4
 
@@ -40,7 +40,7 @@ export async function findAndLoadConfig(targetPath) {
40
40
  try {
41
41
  const absoluteTarget = path.resolve(cwd, targetPath);
42
42
  const stats = await fs.stat(absoluteTarget);
43
-
43
+
44
44
  // If target is a .js file, it might be an explicit config (legacy/internal support)
45
45
  if (stats.isFile() && absoluteTarget.endsWith(".js") && !absoluteTarget.endsWith("smark.config.js")) {
46
46
  configPath = absoluteTarget;
@@ -74,7 +74,7 @@ export async function findAndLoadConfig(targetPath) {
74
74
  // No config found in CWD
75
75
  }
76
76
  }
77
-
77
+
78
78
  const defaultConfig = {
79
79
  outputFile: "output",
80
80
  outputDir: startDir,
@@ -97,12 +97,12 @@ export async function findAndLoadConfig(targetPath) {
97
97
  if (loadedConfig) {
98
98
  // Support both mapperFile and mappingFile (backwards compatibility)
99
99
  const finalMapper = loadedConfig.mapperFile || loadedConfig.mappingFile || defaultConfig.mapperFile;
100
-
101
- const finalConfig = {
102
- ...defaultConfig,
103
- ...loadedConfig,
100
+
101
+ const finalConfig = {
102
+ ...defaultConfig,
103
+ ...loadedConfig,
104
104
  mapperFile: finalMapper,
105
- resolvedConfigPath: configPath
105
+ resolvedConfigPath: configPath
106
106
  };
107
107
  if (loadedConfig.outputDir) {
108
108
  const configDir = path.dirname(configPath);
@@ -18,10 +18,7 @@ export function registerHostSettings(settings) {
18
18
  hostSettings = settings || {};
19
19
  }
20
20
 
21
- const manualVersion = "4.1.0";
22
- const version = await import(new URL("../../package.json", import.meta.url), { with: { type: "json" } })
23
- .then(pkg => pkg.default.version || manualVersion)
24
- .catch(() => manualVersion);
21
+ const version = "4.2.0";
25
22
 
26
23
  const SomMark = {
27
24
  version,
@@ -1,9 +1,24 @@
1
- import path from "node:path";
2
- import fs from "node:fs";
1
+ import path from "pathe";
3
2
  import * as acorn from "acorn";
4
3
  import evaluator from "../evaluator.js";
5
4
  import { transpilerError } from "../errors.js";
6
5
 
6
+ let _nodeFsCache;
7
+ async function getNodeFs() {
8
+ if (_nodeFsCache !== undefined) return _nodeFsCache;
9
+ try {
10
+ const m = await import("node:fs");
11
+ const raw = m.default || m;
12
+ _nodeFsCache = {
13
+ exists: (p) => raw.promises.access(p).then(() => true).catch(() => false),
14
+ readFile: (p, enc) => raw.promises.readFile(p, enc),
15
+ };
16
+ } catch {
17
+ _nodeFsCache = null;
18
+ }
19
+ return _nodeFsCache;
20
+ }
21
+
7
22
  /**
8
23
  * Preprocesses a runtime JS block, parsing it with Acorn to locate
9
24
  * SomMark.static("...") and SomMark.import("...") calls, evaluating/loading them,
@@ -14,7 +29,7 @@ import { transpilerError } from "../errors.js";
14
29
  * @param {Object} security - Security restrictions from the engine configuration.
15
30
  * @returns {Promise<string>} - The preprocessed code.
16
31
  */
17
- export async function preprocessRuntimeLogic(code, filename = null, security = {}) {
32
+ export async function preprocessRuntimeLogic(code, filename = null, security = {}, instance = null) {
18
33
  let ast;
19
34
  try {
20
35
  ast = acorn.parse(code, { ecmaVersion: "latest", sourceType: "module" });
@@ -121,14 +136,16 @@ export async function preprocessRuntimeLogic(code, filename = null, security = {
121
136
  }
122
137
 
123
138
  // Resolve the file path relative to the template's base directory
124
- let baseDir = process.cwd();
139
+ let baseDir = instance?.cwd || "/";
125
140
  if (filename && filename !== "anonymous") {
126
141
  baseDir = path.dirname(path.resolve(filename));
127
142
  }
128
143
  const resolvedPath = path.resolve(baseDir, argValue);
129
144
 
145
+ const fsImpl = instance?.fs || await getNodeFs();
146
+
130
147
  // File presence validation
131
- if (!fs.existsSync(resolvedPath)) {
148
+ if (!fsImpl || !await fsImpl.exists(resolvedPath)) {
132
149
  transpilerError([
133
150
  `<$red:SomMark.import File Error:$> File not found: <$magenta:${argValue}$>{line}`,
134
151
  `<$yellow:Resolved Path:$> <$blue:${resolvedPath}$>{line}`
@@ -145,7 +162,7 @@ export async function preprocessRuntimeLogic(code, filename = null, security = {
145
162
  }
146
163
 
147
164
  let serialized = "";
148
- const content = fs.readFileSync(resolvedPath, "utf-8");
165
+ const content = await fsImpl.readFile(resolvedPath, "utf-8");
149
166
 
150
167
  if (ext === ".json") {
151
168
  // Validate JSON structure
@@ -0,0 +1,12 @@
1
+ export function fileURLToPath(url) {
2
+ if (typeof url !== "string") return url;
3
+ if (url.startsWith("file://")) {
4
+ let p = url.slice(7);
5
+ // On Windows (starts with /C:/ or similar), remove the leading slash:
6
+ if (/^\/[a-zA-Z]:/.test(p)) {
7
+ p = p.slice(1);
8
+ }
9
+ return p;
10
+ }
11
+ return url;
12
+ }
package/core/modules.js CHANGED
@@ -1,6 +1,5 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { fileURLToPath } from "node:url";
1
+ import path from "pathe";
2
+ import { fileURLToPath } from "./helpers/url.js";
4
3
  import { runtimeError } from "./errors.js";
5
4
  import { IMPORT, USE_MODULE, TEXT, BLOCK, COMMENT, SLOT } from "./labels.js";
6
5
 
@@ -8,6 +7,9 @@ import { IMPORT, USE_MODULE, TEXT, BLOCK, COMMENT, SLOT } from "./labels.js";
8
7
  * Resolves a module path relative to a base directory.
9
8
  */
10
9
  const resolveModulePath = (filePath, currentBaseDir) => {
10
+ if (/^https?:\/\//.test(currentBaseDir)) {
11
+ return new URL(filePath, currentBaseDir.endsWith("/") ? currentBaseDir : currentBaseDir + "/").href;
12
+ }
11
13
  return path.resolve(currentBaseDir, filePath);
12
14
  };
13
15
 
@@ -18,13 +20,13 @@ const resolveModulePath = (filePath, currentBaseDir) => {
18
20
  * @param {Object} [aliases={}] - Custom path aliases for modules.
19
21
  * @returns {string} - The corrected absolute path.
20
22
  */
21
- const normalizePath = (filename, aliases = {}) => {
22
- if (!filename || filename === "anonymous") return process.cwd();
23
+ const normalizePath = (filename, aliases = {}, cwd = "/") => {
24
+ if (!filename || filename === "anonymous") return cwd;
23
25
 
24
26
  // Handle Aliases (like @/components)
25
27
  for (const [prefix, replacement] of Object.entries(aliases)) {
26
28
  if (filename.startsWith(prefix)) {
27
- const resolvedPath = path.resolve(process.cwd(), filename.replace(prefix, replacement));
29
+ const resolvedPath = path.resolve(cwd, filename.replace(prefix, replacement));
28
30
  return resolvedPath;
29
31
  }
30
32
  }
@@ -37,7 +39,7 @@ const normalizePath = (filename, aliases = {}) => {
37
39
  }
38
40
  }
39
41
 
40
- return path.resolve(process.cwd(), filename);
42
+ return path.resolve(cwd, filename);
41
43
  };
42
44
 
43
45
  const VAR_PATTERN = /SOMMARK_UNRESOLVED_v_(.+?)_SOMMARK/g;
@@ -135,7 +137,7 @@ export async function resolveModules(ast, context) {
135
137
  const modules = new Map();
136
138
  const filename = context.filename || "anonymous";
137
139
  const importAliases = context.instance.importAliases || {};
138
- const absFilename = normalizePath(filename, importAliases);
140
+ const absFilename = normalizePath(filename, importAliases, context.instance.cwd || "/");
139
141
 
140
142
  // baseDir can be a local path
141
143
  const baseDir = context.instance.baseDir || ((filename === "anonymous") ? absFilename : path.dirname(absFilename));
@@ -231,7 +233,7 @@ export async function resolveModules(ast, context) {
231
233
  let resolvedPath = filePath;
232
234
  for (const [prefix, replacement] of Object.entries(importAliases)) {
233
235
  if (filePath.startsWith(prefix)) {
234
- resolvedPath = path.resolve(process.cwd(), filePath.replace(prefix, replacement));
236
+ resolvedPath = path.resolve(context.instance.cwd || "/", filePath.replace(prefix, replacement));
235
237
  break;
236
238
  }
237
239
  }
@@ -241,11 +243,11 @@ export async function resolveModules(ast, context) {
241
243
 
242
244
  // Local Path Resolution with Auto-Extension
243
245
  let localPath = absolutePath;
244
- if (!fs.existsSync(localPath) && !localPath.endsWith(".smark")) {
246
+ if (!await context.instance.fs.exists(localPath) && !localPath.endsWith(".smark")) {
245
247
  const withSmark = localPath + ".smark";
246
- if (fs.existsSync(withSmark)) localPath = withSmark;
248
+ if (await context.instance.fs.exists(withSmark)) localPath = withSmark;
247
249
  }
248
- if (!fs.existsSync(localPath)) {
250
+ if (!await context.instance.fs.exists(localPath)) {
249
251
  runtimeError([`<$red:Module Path Error:$> File not found: <$magenta:${filePath}$> at line <$yellow:${node.range.start.line + 1}$>`]);
250
252
  }
251
253
  let mod = { path: absolutePath, localPath: localPath, type: "smark" };
@@ -289,7 +291,7 @@ export async function resolveModules(ast, context) {
289
291
  if (cached) {
290
292
  expandedNodes = trimAst(cloneAst(cached));
291
293
  } else {
292
- const content = fs.readFileSync(mod.localPath, "utf-8");
294
+ const content = await context.instance.fs.readFile(mod.localPath, "utf-8");
293
295
  const SomMark = context.instance.constructor;
294
296
  const subSmark = new SomMark({
295
297
  src: content,
@@ -347,7 +349,7 @@ export async function resolveModules(ast, context) {
347
349
  if (cached) {
348
350
  subAst = cloneAst(cached);
349
351
  } else {
350
- const content = fs.readFileSync(mod.localPath, "utf-8");
352
+ const content = await context.instance.fs.readFile(mod.localPath, "utf-8");
351
353
  const SomMark = context.instance.constructor;
352
354
  const subSmark = new SomMark({
353
355
  src: content,
@@ -1,4 +1,3 @@
1
- import crypto from "node:crypto";
2
1
  import { BLOCK, TEXT, INLINE, ATBLOCK, COMMENT, COMMENT_BLOCK, STATIC_LOGIC, RUNTIME_LOGIC, FOR_EACH } from "./labels.js";
3
2
  import { transpilerError } from "./errors.js";
4
3
  import evaluator from "./evaluator.js";
@@ -7,13 +6,13 @@ import { dedentBy } from "../helpers/dedent.js";
7
6
  import { preprocessRuntimeLogic } from "./helpers/preprocessor.js";
8
7
  import { wrapRuntimeLogic } from "./helpers/runtimeOutput.js";
9
8
 
10
- /**
11
- * SomMark Transpiler
12
- * This engine converts the AST into its final text format (like HTML or Markdown)
13
- * using rules provided by a mapper.
14
- */
9
+ const randomBytesHex = (size) => {
10
+ const arr = new Uint8Array(size);
11
+ globalThis.crypto.getRandomValues(arr);
12
+ return Array.from(arr).map(b => b.toString(16).padStart(2, "0")).join("");
13
+ };
15
14
 
16
- const BODY_PLACEHOLDER = `SOMMARKBODYPLACEHOLDER${crypto.randomBytes(8).toString("hex")}SOMMARK`;
15
+ const BODY_PLACEHOLDER = `SOMMARKBODYPLACEHOLDER${randomBytesHex(8)}SOMMARK`;
17
16
 
18
17
  /**
19
18
  * Extracts all plain text from a node and its children.
@@ -46,7 +45,7 @@ function getNodeText(node) {
46
45
  * @param {Object} mapper_file - The rules for how to convert each node.
47
46
  * @returns {Promise<string>} - The final text for this node.
48
47
  */
49
- async function generateOutput(ast, i, format, mapper_file, security = {}, parentId = null, generateRuntimeOutput = false, hideRuntimeOutput = false) {
48
+ async function generateOutput(ast, i, format, mapper_file, security = {}, parentId = null, generateRuntimeOutput = false, hideRuntimeOutput = false, instance = null) {
50
49
  const node = Array.isArray(ast) ? ast[i] : ast;
51
50
  if (!node) return "";
52
51
 
@@ -61,7 +60,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
61
60
  if (node.body) {
62
61
  evaluator.pushScope();
63
62
  for (let j = 0; j < node.body.length; j++) {
64
- bodyOutput += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput);
63
+ bodyOutput += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
65
64
  }
66
65
  await evaluator.popScope();
67
66
  }
@@ -86,7 +85,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
86
85
  }
87
86
 
88
87
  if (node.type === RUNTIME_LOGIC) {
89
- const preprocessed = await preprocessRuntimeLogic(node.code, mapper_file?.options?.filename, security);
88
+ const preprocessed = await preprocessRuntimeLogic(node.code, mapper_file?.options?.filename, security, instance);
90
89
  if (hideRuntimeOutput) {
91
90
  return "";
92
91
  }
@@ -169,7 +168,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
169
168
  });
170
169
 
171
170
  for (let j = 0; j < cleanedBody.length; j++) {
172
- output += await generateOutput(cleanedBody, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput);
171
+ output += await generateOutput(cleanedBody, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
173
172
  }
174
173
 
175
174
  await evaluator.popScope();
@@ -192,7 +191,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
192
191
 
193
192
  const hasRuntime = node.body?.some(child => child.type === RUNTIME_LOGIC);
194
193
  if (hasRuntime) {
195
- secretId = `sommark-${node.id.toLowerCase()}-${crypto.randomBytes(4).toString("hex")}`;
194
+ secretId = `sommark-${node.id.toLowerCase()}-${randomBytesHex(4)}`;
196
195
  }
197
196
  }
198
197
 
@@ -248,7 +247,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
248
247
  let resolvedBody = "";
249
248
  evaluator.pushScope();
250
249
  for (let j = 0; j < node.body.length; j++) {
251
- resolvedBody += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput);
250
+ resolvedBody += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
252
251
  }
253
252
  await evaluator.popScope();
254
253
  content = dedentBy(resolvedBody, node.range?.start?.character || 0);
@@ -258,7 +257,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
258
257
  let childrenOutput = "";
259
258
  if (node.body) {
260
259
  for (let j = 0; j < node.body.length; j++) {
261
- childrenOutput += await generateOutput(node.body, j, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput);
260
+ childrenOutput += await generateOutput(node.body, j, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
262
261
  }
263
262
  }
264
263
  return childrenOutput;
@@ -306,8 +305,8 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
306
305
  switch (body_node.type) {
307
306
  case TEXT:
308
307
  const text = String(body_node.text || "");
309
- // Dedent text relative to the parent block's indentation
310
- const localDedentedText = dedentBy(text, node.range?.start?.character || 0);
308
+ // Only dedent multi-line text inline spaces (no newlines) are separators, not indentation
309
+ const localDedentedText = text.includes("\n") ? dedentBy(text, node.range?.start?.character || 0) : text;
311
310
  let bodyTextVal = mapper_file ? mapper_file.text(localDedentedText, { ...target?.options, escape: parentEscape }) : localDedentedText;
312
311
  if (parentEscape === false && security?.sanitize && typeof security.sanitize === "function") {
313
312
  bodyTextVal = security.sanitize(bodyTextVal);
@@ -370,11 +369,11 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
370
369
 
371
370
  case FOR_EACH:
372
371
  case BLOCK:
373
- bodyOutput = await generateOutput(body_node, 0, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput);
372
+ bodyOutput = await generateOutput(body_node, 0, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
374
373
  break;
375
374
 
376
375
  case RUNTIME_LOGIC:
377
- const preprocessedBody = await preprocessRuntimeLogic(body_node.code, mapper_file?.options?.filename, security);
376
+ const preprocessedBody = await preprocessRuntimeLogic(body_node.code, mapper_file?.options?.filename, security, instance);
378
377
  if (hideRuntimeOutput) {
379
378
  bodyOutput = "";
380
379
  } else {
@@ -483,6 +482,11 @@ export async function transpiler(optionsOrAst, format, mapperFile) {
483
482
  if (!body || !Array.isArray(body)) return "";
484
483
 
485
484
  const settings = optionsOrAst?.settings || { format: targetFormat || "html" };
485
+ const instance = optionsOrAst?.instance;
486
+ if (instance) {
487
+ settings.instance = instance;
488
+ settings.fs = instance.fs;
489
+ }
486
490
 
487
491
  // Initialize Logic Sandbox
488
492
  await evaluator.init(null, security, settings, targetMapper);
@@ -500,7 +504,7 @@ export async function transpiler(optionsOrAst, format, mapperFile) {
500
504
  try {
501
505
  for (let i = 0; i < body.length; i++) {
502
506
  const node = body[i];
503
- const blockOutput = await generateOutput(body, i, targetFormat, targetMapper, security, null, generateRuntimeOutput, hideRuntimeOutput);
507
+ const blockOutput = await generateOutput(body, i, targetFormat, targetMapper, security, null, generateRuntimeOutput, hideRuntimeOutput, instance);
504
508
 
505
509
  let finalBlockOutput = blockOutput;
506
510
  if (prev_was_silent && node.type === TEXT) {
@@ -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
+ }
@@ -9,7 +9,11 @@ let originalStderrWrite = null;
9
9
  let redrawTimeout = null;
10
10
 
11
11
  export function startSpinner() {
12
- if (process.stdout.isTTY && !activeSpinner) {
12
+ if (typeof process === "undefined" || !process.stdout?.isTTY) {
13
+ spinnerDepth++;
14
+ return;
15
+ }
16
+ if (!activeSpinner) {
13
17
  // Hide terminal cursor for a clean premium visual feel
14
18
  process.stdout.write("\x1b[?25l");
15
19
 
@@ -84,8 +88,10 @@ export function stopSpinner() {
84
88
  originalStderrWrite = null;
85
89
  }
86
90
 
91
+ if (typeof process !== "undefined" && process.stdout) {
87
92
  // Clear the spinner line and restore the terminal cursor
88
93
  process.stdout.write("\r\x1b[K\x1b[?25h");
89
94
  }
95
+ }
90
96
  }
91
97
  }
@@ -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;