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.
- package/README.md +34 -4
- package/core/evaluator.js +408 -132
- package/core/helpers/config-loader.js +8 -8
- package/core/helpers/lib.js +1 -4
- package/core/helpers/preprocessor.js +23 -6
- package/core/helpers/url.js +12 -0
- package/core/modules.js +16 -14
- package/core/transpiler.js +23 -19
- package/helpers/fetch-fs.js +37 -0
- package/helpers/spinner.js +7 -1
- package/helpers/virtual-fs.js +29 -0
- package/index.browser.js +87 -0
- package/index.js +23 -419
- package/index.shared.js +443 -0
- package/package.json +8 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import path from "
|
|
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);
|
package/core/helpers/lib.js
CHANGED
|
@@ -18,10 +18,7 @@ export function registerHostSettings(settings) {
|
|
|
18
18
|
hostSettings = settings || {};
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const
|
|
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 "
|
|
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 =
|
|
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 (!
|
|
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 =
|
|
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
|
|
2
|
-
import
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
246
|
+
if (!await context.instance.fs.exists(localPath) && !localPath.endsWith(".smark")) {
|
|
245
247
|
const withSmark = localPath + ".smark";
|
|
246
|
-
if (fs.
|
|
248
|
+
if (await context.instance.fs.exists(withSmark)) localPath = withSmark;
|
|
247
249
|
}
|
|
248
|
-
if (!fs.
|
|
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.
|
|
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.
|
|
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,
|
package/core/transpiler.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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${
|
|
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()}-${
|
|
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
|
-
//
|
|
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
|
+
}
|
package/helpers/spinner.js
CHANGED
|
@@ -9,7 +9,11 @@ let originalStderrWrite = null;
|
|
|
9
9
|
let redrawTimeout = null;
|
|
10
10
|
|
|
11
11
|
export function startSpinner() {
|
|
12
|
-
if (process.stdout
|
|
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
|
+
}
|
package/index.browser.js
ADDED
|
@@ -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;
|