sommark 5.0.5 → 5.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/async-hooks.js +19 -0
- package/core/evaluator.js +212 -22
- package/core/helpers/config-loader.js +29 -9
- package/core/helpers/lib.js +1 -1
- package/core/helpers/preprocessor.js +19 -0
- package/core/modules.js +59 -21
- package/core/parser.js +6 -3
- package/core/pathe-bundle.js +1 -0
- package/core/transpiler.js +130 -15
- package/core/validator.js +17 -4
- package/dist/sommark.browser.js +669 -214
- package/dist/sommark.browser.lite.js +458 -191
- package/dist/sommark.parser.js +6 -3
- package/esbuild.js +64 -0
- package/index.browser.js +4 -1
- package/index.js +102 -1
- package/index.shared.js +13 -2
- package/mappers/languages/markdown.js +99 -81
- package/mappers/languages/xml.js +76 -61
- package/mappers/shared/index.js +10 -1
- package/package.json +11 -3
- package/rollup.js +79 -0
- package/vite.js +20 -0
package/async-hooks.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class AsyncLocalStorage {
|
|
2
|
+
#store = undefined;
|
|
3
|
+
run(store, fn) {
|
|
4
|
+
const prev = this.#store;
|
|
5
|
+
this.#store = store;
|
|
6
|
+
try { return fn(); }
|
|
7
|
+
finally { this.#store = prev; }
|
|
8
|
+
}
|
|
9
|
+
getStore() { return this.#store; }
|
|
10
|
+
exit(fn) { return fn(); }
|
|
11
|
+
enterWith(store) { this.#store = store; }
|
|
12
|
+
disable() {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class AsyncResource {
|
|
16
|
+
static bind(fn) { return fn; }
|
|
17
|
+
bind(fn) { return fn; }
|
|
18
|
+
runInAsyncScope(fn) { return fn(); }
|
|
19
|
+
}
|
package/core/evaluator.js
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
1
|
import { getQuickJS } from "quickjs-emscripten";
|
|
3
2
|
import path from "pathe";
|
|
4
3
|
import * as acorn from "acorn";
|
|
5
4
|
import SomMark, { registerHostCompile, registerHostSettings } from "./helpers/lib.js";
|
|
6
5
|
import { formatMessage } from "./errors.js";
|
|
6
|
+
import { patheBundleCode } from "./pathe-bundle.js";
|
|
7
|
+
import lexer from "./lexer.js";
|
|
8
|
+
import parser from "./parser.js";
|
|
7
9
|
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
+
// Set by index.js (Node.js) or index.browser.js (shim) — never imported directly.
|
|
11
|
+
let evaluatorStorage = null;
|
|
12
|
+
|
|
13
|
+
export function setDefaultAsyncLocalStorage(cls) {
|
|
14
|
+
evaluatorStorage = cls ? new cls() : null;
|
|
15
|
+
}
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
18
|
* Runs fn inside an isolated evaluator context.
|
|
13
19
|
* Concurrent transpile() calls each get their own stack — no cross-contamination.
|
|
14
20
|
*/
|
|
15
21
|
export function withEvaluator(fn) {
|
|
22
|
+
if (!evaluatorStorage) return fn();
|
|
16
23
|
return evaluatorStorage.run([], fn);
|
|
17
24
|
}
|
|
18
25
|
|
|
@@ -141,7 +148,7 @@ const customFetchAdapter = async (input, init, security = {}) => {
|
|
|
141
148
|
};
|
|
142
149
|
};
|
|
143
150
|
|
|
144
|
-
const customCompileAdapter = async (src, options, parentSecurity = {}) => {
|
|
151
|
+
const customCompileAdapter = async (src, options, parentSecurity = {}, parentFs = null, parentBaseDir = null) => {
|
|
145
152
|
const maxDepth = parentSecurity?.maxDepth ?? 5;
|
|
146
153
|
if (globalCompilationDepth >= maxDepth) {
|
|
147
154
|
throw new Error(`Recursion Guard: Maximum Smark compilation depth exceeded (limit is ${maxDepth}).`);
|
|
@@ -157,7 +164,9 @@ const customCompileAdapter = async (src, options, parentSecurity = {}) => {
|
|
|
157
164
|
...cleanOptions,
|
|
158
165
|
src,
|
|
159
166
|
format: cleanOptions.format || "html",
|
|
160
|
-
security: parentSecurity
|
|
167
|
+
security: parentSecurity,
|
|
168
|
+
fs: parentFs ?? undefined,
|
|
169
|
+
baseDir: cleanOptions.baseDir || parentBaseDir || undefined,
|
|
161
170
|
};
|
|
162
171
|
const sm = new compilerClass(compilerOptions);
|
|
163
172
|
return await sm.transpile();
|
|
@@ -170,6 +179,7 @@ const customCompileAdapter = async (src, options, parentSecurity = {}) => {
|
|
|
170
179
|
registerHostCompile(customCompileAdapter);
|
|
171
180
|
|
|
172
181
|
let defaultFs = null;
|
|
182
|
+
let defaultEnv = null;
|
|
173
183
|
let quickJSInstance = null;
|
|
174
184
|
async function getQuickJSModule() {
|
|
175
185
|
if (!quickJSInstance) {
|
|
@@ -193,6 +203,16 @@ function objectToHandle(context, obj) {
|
|
|
193
203
|
return result.unwrap();
|
|
194
204
|
}
|
|
195
205
|
|
|
206
|
+
function isPlainData(value, seen = new Set()) {
|
|
207
|
+
if (value === null || value === undefined) return true;
|
|
208
|
+
if (typeof value === "function") return false;
|
|
209
|
+
if (typeof value !== "object") return true;
|
|
210
|
+
if (seen.has(value)) return false;
|
|
211
|
+
seen.add(value);
|
|
212
|
+
if (Array.isArray(value)) return value.every(v => isPlainData(v, seen));
|
|
213
|
+
return Object.values(value).every(v => isPlainData(v, seen));
|
|
214
|
+
}
|
|
215
|
+
|
|
196
216
|
function expose(context, vars, pendingDeferreds) {
|
|
197
217
|
for (const [key, value] of Object.entries(vars)) {
|
|
198
218
|
let handle;
|
|
@@ -287,6 +307,7 @@ class EvaluatorState {
|
|
|
287
307
|
} else {
|
|
288
308
|
this.baseDir = "/";
|
|
289
309
|
}
|
|
310
|
+
this.rootDir = settings?.instance?.cwd || this.baseDir;
|
|
290
311
|
this.scopes = [{}];
|
|
291
312
|
this.dynamicTagsStack = [new Map()];
|
|
292
313
|
this.security = security;
|
|
@@ -313,15 +334,39 @@ class EvaluatorState {
|
|
|
313
334
|
});
|
|
314
335
|
|
|
315
336
|
this.expose({
|
|
337
|
+
__hostEnv: (key) => {
|
|
338
|
+
if (defaultEnv === null) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
"[SomMark] SomMark.env() is not available in browser mode.\n" +
|
|
341
|
+
"Environment variables are a server-side concept.\n" +
|
|
342
|
+
"Read env values at build time and pass them as placeholders instead."
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const allowlist = this.security?.env;
|
|
346
|
+
if (!Array.isArray(allowlist) || !allowlist.includes(key)) return undefined;
|
|
347
|
+
return defaultEnv[key] ?? undefined;
|
|
348
|
+
},
|
|
316
349
|
__hostSomMarkVersion: SomMark.version,
|
|
317
350
|
__hostSomMarkSettings: () => {
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
351
|
+
const s = SomMark.settings;
|
|
352
|
+
return JSON.stringify({
|
|
353
|
+
format: s.format ?? null,
|
|
354
|
+
dev: s.dev ?? false,
|
|
355
|
+
removeComments:s.removeComments ?? false,
|
|
356
|
+
allowRaw: s.allowRaw ?? true,
|
|
357
|
+
dualOutput: s.dualOutput ?? false,
|
|
358
|
+
webOutputs: s.webOutputs ?? false,
|
|
359
|
+
});
|
|
360
|
+
},
|
|
323
361
|
__hostCompile: async (src, options) => {
|
|
324
|
-
return await customCompileAdapter(src, options, this.security);
|
|
362
|
+
return await customCompileAdapter(src, options, this.security, this.nodeFs, this.baseDir);
|
|
363
|
+
},
|
|
364
|
+
__hostLexer: (src, filename) => {
|
|
365
|
+
return JSON.stringify(lexer(src, filename || "anonymous"));
|
|
366
|
+
},
|
|
367
|
+
__hostParser: (src, filename) => {
|
|
368
|
+
const tokens = lexer(src, filename || "anonymous");
|
|
369
|
+
return JSON.stringify(parser(tokens, filename || "anonymous"));
|
|
325
370
|
},
|
|
326
371
|
__hostFetch: async (input, initStr) => {
|
|
327
372
|
const init = initStr ? JSON.parse(initStr) : undefined;
|
|
@@ -349,6 +394,59 @@ class EvaluatorState {
|
|
|
349
394
|
const payload = JSON.parse(payloadStr);
|
|
350
395
|
return await target.render.call(this.mapperFile, payload);
|
|
351
396
|
},
|
|
397
|
+
__hostFileRead: async (filePath) => {
|
|
398
|
+
if (!this.nodeFs) {
|
|
399
|
+
throw new Error(
|
|
400
|
+
"[SomMark] fileHandler is not available in browser mode.\n" +
|
|
401
|
+
"File access is a server-side concept."
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
const abs = path.resolve(this.rootDir, filePath);
|
|
405
|
+
if (!abs.startsWith(this.rootDir)) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`[SomMark] fileHandler.read: path traversal outside project root is not allowed.\n` +
|
|
408
|
+
`Attempted path: ${abs}`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return this.nodeFs.readFile(abs, "utf-8");
|
|
412
|
+
},
|
|
413
|
+
__hostFileExists: async (filePath) => {
|
|
414
|
+
if (!this.nodeFs) return false;
|
|
415
|
+
const abs = path.resolve(this.rootDir, filePath);
|
|
416
|
+
if (!abs.startsWith(this.rootDir)) return false;
|
|
417
|
+
return this.nodeFs.exists(abs);
|
|
418
|
+
},
|
|
419
|
+
__hostFileGlob: async (pattern) => {
|
|
420
|
+
if (!this.nodeFs) throw new Error("[SomMark] fileHandler.glob is not available in browser mode.\nFile access is a server-side concept.");
|
|
421
|
+
if (!this.nodeFs.glob) throw new Error("[SomMark] fileHandler.glob requires Node.js 22 or later.");
|
|
422
|
+
const files = await this.nodeFs.glob(pattern, { cwd: this.rootDir });
|
|
423
|
+
return JSON.stringify(files);
|
|
424
|
+
},
|
|
425
|
+
__hostFileLastModified: async (filePath) => {
|
|
426
|
+
if (!this.nodeFs) throw new Error("[SomMark] fileHandler.lastModified is not available in browser mode.");
|
|
427
|
+
const abs = path.resolve(this.rootDir, filePath);
|
|
428
|
+
if (!abs.startsWith(this.rootDir)) throw new Error("[SomMark] fileHandler.lastModified: path traversal outside project root is not allowed.");
|
|
429
|
+
const stat = await this.nodeFs.stat(abs);
|
|
430
|
+
return stat.mtimeMs;
|
|
431
|
+
},
|
|
432
|
+
__hostFileStat: async (filePath) => {
|
|
433
|
+
if (!this.nodeFs) throw new Error("[SomMark] fileHandler.stat is not available in browser mode.\nFile access is a server-side concept.");
|
|
434
|
+
const abs = path.resolve(this.rootDir, filePath);
|
|
435
|
+
if (!abs.startsWith(this.rootDir)) throw new Error(`[SomMark] fileHandler.stat: path traversal outside project root is not allowed.\nAttempted path: ${abs}`);
|
|
436
|
+
try {
|
|
437
|
+
const s = await this.nodeFs.stat(abs);
|
|
438
|
+
return JSON.stringify({
|
|
439
|
+
size: s.size,
|
|
440
|
+
mtime: s.mtimeMs,
|
|
441
|
+
ctime: s.ctimeMs,
|
|
442
|
+
atime: s.atimeMs,
|
|
443
|
+
isFile: s.isFile(),
|
|
444
|
+
isDirectory: s.isDirectory(),
|
|
445
|
+
});
|
|
446
|
+
} catch {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
},
|
|
352
450
|
__allowRaw: this.security.allowRaw !== false
|
|
353
451
|
});
|
|
354
452
|
|
|
@@ -501,6 +599,18 @@ class EvaluatorState {
|
|
|
501
599
|
}
|
|
502
600
|
return await __hostCompile(src, options);
|
|
503
601
|
},
|
|
602
|
+
lexer: (src, filename) => {
|
|
603
|
+
if (typeof src !== "string") {
|
|
604
|
+
throw new Error("SomMark.lexer Error: Source must be a string.");
|
|
605
|
+
}
|
|
606
|
+
return JSON.parse(__hostLexer(src, filename));
|
|
607
|
+
},
|
|
608
|
+
parser: (src, filename) => {
|
|
609
|
+
if (typeof src !== "string") {
|
|
610
|
+
throw new Error("SomMark.parser Error: Source must be a string.");
|
|
611
|
+
}
|
|
612
|
+
return JSON.parse(__hostParser(src, filename));
|
|
613
|
+
},
|
|
504
614
|
raw: (html) => {
|
|
505
615
|
if (typeof __allowRaw !== "undefined" && !__allowRaw) {
|
|
506
616
|
throw new Error("Security Error: SomMark.raw is disabled in this environment.");
|
|
@@ -524,6 +634,12 @@ class EvaluatorState {
|
|
|
524
634
|
throw new Error("SomMark.static Error: Argument must be a string.");
|
|
525
635
|
}
|
|
526
636
|
return globalThis.eval(expr);
|
|
637
|
+
},
|
|
638
|
+
env: (key) => {
|
|
639
|
+
if (typeof key !== "string" || !key) {
|
|
640
|
+
throw new Error("SomMark.env Error: Key must be a non-empty string.");
|
|
641
|
+
}
|
|
642
|
+
return __hostEnv(key);
|
|
527
643
|
}
|
|
528
644
|
};
|
|
529
645
|
|
|
@@ -535,6 +651,20 @@ class EvaluatorState {
|
|
|
535
651
|
configurable: false
|
|
536
652
|
});
|
|
537
653
|
|
|
654
|
+
Object.defineProperty(globalThis, "Smark", {
|
|
655
|
+
value: SomMark,
|
|
656
|
+
writable: false,
|
|
657
|
+
configurable: false
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
globalThis.fileHandler = Object.freeze({
|
|
661
|
+
read: async (path) => await __hostFileRead(path),
|
|
662
|
+
exists: async (path) => await __hostFileExists(path),
|
|
663
|
+
glob: async (pattern) => JSON.parse(await __hostFileGlob(pattern)),
|
|
664
|
+
lastModified: async (path) => await __hostFileLastModified(path),
|
|
665
|
+
stat: async (path) => { const r = await __hostFileStat(path); return r ? JSON.parse(r) : null; },
|
|
666
|
+
});
|
|
667
|
+
|
|
538
668
|
delete globalThis.fetch;
|
|
539
669
|
delete globalThis.process;
|
|
540
670
|
`);
|
|
@@ -546,15 +676,27 @@ class EvaluatorState {
|
|
|
546
676
|
}
|
|
547
677
|
setupRes.value.dispose();
|
|
548
678
|
|
|
549
|
-
|
|
679
|
+
const patheRes = this.context.evalCode(patheBundleCode);
|
|
680
|
+
if (patheRes.error) {
|
|
681
|
+
patheRes.error.dispose();
|
|
682
|
+
} else {
|
|
683
|
+
patheRes.value.dispose();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Configure module loader using virtual FS implementation.
|
|
687
|
+
// The normalizer resolves every import to an absolute path so the module
|
|
688
|
+
// cache key is always absolute — <smark> (the eval module name) can never
|
|
689
|
+
// be reached by any user import regardless of what the file is named.
|
|
550
690
|
this.runtime.setModuleLoader((moduleName) => {
|
|
551
691
|
try {
|
|
552
692
|
const isRaw = moduleName.endsWith("?raw");
|
|
553
693
|
const cleanModuleName = isRaw ? moduleName.slice(0, -4) : moduleName;
|
|
554
|
-
|
|
555
|
-
|
|
694
|
+
// moduleName is already an absolute path (supplied by the normalizer below),
|
|
695
|
+
// so resolve() is a no-op for absolute paths and a safe fallback for URLs.
|
|
696
|
+
const resolvedPath = /^https?:\/\//.test(cleanModuleName)
|
|
697
|
+
? cleanModuleName
|
|
556
698
|
: path.resolve(this.baseDir, cleanModuleName);
|
|
557
|
-
|
|
699
|
+
|
|
558
700
|
const fsImpl = this.settings?.fs || this.settings?.instance?.fs || this.nodeFs;
|
|
559
701
|
if (!fsImpl) {
|
|
560
702
|
throw new Error("No filesystem implementation available.");
|
|
@@ -587,6 +729,22 @@ class EvaluatorState {
|
|
|
587
729
|
} catch (err) {
|
|
588
730
|
throw err;
|
|
589
731
|
}
|
|
732
|
+
}, (baseName, moduleName) => {
|
|
733
|
+
// Resolve every import to an absolute path so no user import can ever
|
|
734
|
+
// normalize to <smark> (or any other virtual eval module name).
|
|
735
|
+
const isRaw = moduleName.endsWith("?raw");
|
|
736
|
+
const clean = isRaw ? moduleName.slice(0, -4) : moduleName;
|
|
737
|
+
if (/^https?:\/\//.test(clean)) return moduleName;
|
|
738
|
+
const baseDir = (baseName === "<smark>" || !path.isAbsolute(baseName))
|
|
739
|
+
? this.baseDir
|
|
740
|
+
: (/^https?:\/\//.test(baseName) ? baseName : path.dirname(baseName));
|
|
741
|
+
let resolved;
|
|
742
|
+
if (/^https?:\/\//.test(baseDir)) {
|
|
743
|
+
resolved = new URL(clean, baseDir).href;
|
|
744
|
+
} else {
|
|
745
|
+
resolved = path.resolve(baseDir, clean);
|
|
746
|
+
}
|
|
747
|
+
return isRaw ? resolved + "?raw" : resolved;
|
|
590
748
|
});
|
|
591
749
|
}
|
|
592
750
|
|
|
@@ -737,9 +895,24 @@ class EvaluatorState {
|
|
|
737
895
|
|
|
738
896
|
inject(vars) {
|
|
739
897
|
if (!this.context) return;
|
|
898
|
+
const safe = {};
|
|
899
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
900
|
+
if (typeof value === "function") {
|
|
901
|
+
const src = value.toString();
|
|
902
|
+
if (src.includes("SomMark.")) {
|
|
903
|
+
console.warn(`[SomMark] variables.${key}: references 'SomMark' which bundlers may rename. Use 'Smark' instead.`);
|
|
904
|
+
}
|
|
905
|
+
const res = this.context.evalCode(`globalThis[${JSON.stringify(key)}] = ${src}`);
|
|
906
|
+
if (res.error) res.error.dispose();
|
|
907
|
+
else res.value.dispose();
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
if (!isPlainData(value)) continue;
|
|
911
|
+
safe[key] = value;
|
|
912
|
+
}
|
|
740
913
|
const currentScope = this.scopes[this.scopes.length - 1];
|
|
741
|
-
Object.assign(currentScope,
|
|
742
|
-
this.expose(
|
|
914
|
+
Object.assign(currentScope, safe);
|
|
915
|
+
this.expose(safe);
|
|
743
916
|
}
|
|
744
917
|
|
|
745
918
|
async execute(code, baseDir = null) {
|
|
@@ -809,7 +982,16 @@ class EvaluatorState {
|
|
|
809
982
|
}
|
|
810
983
|
}
|
|
811
984
|
} catch (err) {
|
|
812
|
-
//
|
|
985
|
+
// Parse failed as a statement — try as a parenthesised expression.
|
|
986
|
+
// This handles object/array literals like {a: 1} or [1, 2] which are
|
|
987
|
+
// ambiguous in statement context but valid when wrapped in parens.
|
|
988
|
+
try {
|
|
989
|
+
const trimmed = code.trim();
|
|
990
|
+
acorn.parse(`(${trimmed})`, { ecmaVersion: 'latest', sourceType: 'module' });
|
|
991
|
+
finalCode = `export default (${trimmed});`;
|
|
992
|
+
} catch {
|
|
993
|
+
// Give up — let QuickJS surface the error.
|
|
994
|
+
}
|
|
813
995
|
}
|
|
814
996
|
|
|
815
997
|
if (autoExportedNames.length > 0 && !hasExplicitExports) {
|
|
@@ -823,7 +1005,7 @@ class EvaluatorState {
|
|
|
823
1005
|
|
|
824
1006
|
let result;
|
|
825
1007
|
if (isModule) {
|
|
826
|
-
const evalRes = this.context.evalCode(finalCode, "
|
|
1008
|
+
const evalRes = this.context.evalCode(finalCode, "<smark>", { type: 'module' });
|
|
827
1009
|
if (evalRes.error) {
|
|
828
1010
|
const err = this.context.dump(evalRes.error);
|
|
829
1011
|
evalRes.error.dispose();
|
|
@@ -892,7 +1074,7 @@ class EvaluatorState {
|
|
|
892
1074
|
}
|
|
893
1075
|
|
|
894
1076
|
const defaultValue = this.context.dump(resolvedDefaultHandle);
|
|
895
|
-
|
|
1077
|
+
|
|
896
1078
|
if (isPromise) {
|
|
897
1079
|
resolvedDefaultHandle.dispose();
|
|
898
1080
|
}
|
|
@@ -932,7 +1114,7 @@ class EvaluatorState {
|
|
|
932
1114
|
result = res;
|
|
933
1115
|
}
|
|
934
1116
|
} else {
|
|
935
|
-
const evalRes = this.context.evalCode(code, "
|
|
1117
|
+
const evalRes = this.context.evalCode(code, "<smark>");
|
|
936
1118
|
if (evalRes.error) {
|
|
937
1119
|
const err = this.context.dump(evalRes.error);
|
|
938
1120
|
evalRes.error.dispose();
|
|
@@ -968,7 +1150,7 @@ class EvaluatorState {
|
|
|
968
1150
|
return result;
|
|
969
1151
|
} catch (error) {
|
|
970
1152
|
const stack = error.stack || "";
|
|
971
|
-
const match = stack.match(/
|
|
1153
|
+
const match = stack.match(/__smark__\.js:(\d+):(\d+)/) || stack.match(/:(\d+):(\d+)/);
|
|
972
1154
|
|
|
973
1155
|
const err = new Error(error.message || error);
|
|
974
1156
|
if (match) {
|
|
@@ -1031,6 +1213,14 @@ class Evaluator {
|
|
|
1031
1213
|
defaultFs = fs;
|
|
1032
1214
|
}
|
|
1033
1215
|
|
|
1216
|
+
setDefaultEnv(env) {
|
|
1217
|
+
defaultEnv = env;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
setDefaultAsyncLocalStorage(cls) {
|
|
1221
|
+
setDefaultAsyncLocalStorage(cls);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1034
1224
|
get active() {
|
|
1035
1225
|
const stack = this._getStack();
|
|
1036
1226
|
if (stack.length === 0) {
|
|
@@ -52,19 +52,24 @@ export async function findAndLoadConfig(targetPath) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
// 2.
|
|
55
|
+
// 2. Walk up from startDir looking for the config file
|
|
56
56
|
if (!configPath) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
let dir = startDir;
|
|
58
|
+
const root = path.parse(dir).root;
|
|
59
|
+
while (dir !== root) {
|
|
60
|
+
const candidate = path.join(dir, CONFIG_FILE_NAME);
|
|
61
|
+
try {
|
|
62
|
+
await fs.access(candidate);
|
|
63
|
+
configPath = candidate;
|
|
64
|
+
break;
|
|
65
|
+
} catch {
|
|
66
|
+
dir = path.dirname(dir);
|
|
67
|
+
}
|
|
63
68
|
}
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
// 3. Check the Current Working Directory (Fallback)
|
|
67
|
-
// We only check CWD if it's different from the Target Directory
|
|
72
|
+
// We only check CWD if it's different from the Target Directory and walk didn't find anything
|
|
68
73
|
if (!configPath && startDir !== cwd) {
|
|
69
74
|
const cwdConfig = path.join(cwd, CONFIG_FILE_NAME);
|
|
70
75
|
try {
|
|
@@ -102,10 +107,25 @@ export async function findAndLoadConfig(targetPath) {
|
|
|
102
107
|
mapperFile: finalMapper,
|
|
103
108
|
resolvedConfigPath: configPath
|
|
104
109
|
};
|
|
110
|
+
|
|
111
|
+
const configDir = path.dirname(configPath);
|
|
112
|
+
|
|
105
113
|
if (loadedConfig.outputDir) {
|
|
106
|
-
const configDir = path.dirname(configPath);
|
|
107
114
|
finalConfig.outputDir = path.resolve(configDir, loadedConfig.outputDir);
|
|
108
115
|
}
|
|
116
|
+
|
|
117
|
+
// Resolve relative alias paths against the config file's directory so that
|
|
118
|
+
// "./node_modules/..." in a nested config resolves correctly regardless of cwd
|
|
119
|
+
if (loadedConfig.importAliases) {
|
|
120
|
+
const resolved = {};
|
|
121
|
+
for (const [key, val] of Object.entries(loadedConfig.importAliases)) {
|
|
122
|
+
resolved[key] = (val.startsWith("./") || val.startsWith("../"))
|
|
123
|
+
? path.resolve(configDir, val)
|
|
124
|
+
: val;
|
|
125
|
+
}
|
|
126
|
+
finalConfig.importAliases = resolved;
|
|
127
|
+
}
|
|
128
|
+
|
|
109
129
|
return finalConfig;
|
|
110
130
|
}
|
|
111
131
|
}
|
package/core/helpers/lib.js
CHANGED
|
@@ -140,8 +140,27 @@ export async function preprocessRuntimeLogic(code, filename = null, security = {
|
|
|
140
140
|
if (filename && filename !== "anonymous") {
|
|
141
141
|
baseDir = path.dirname(path.resolve(filename));
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
// Block absolute paths — path.resolve would ignore baseDir entirely
|
|
145
|
+
if (path.isAbsolute(argValue)) {
|
|
146
|
+
transpilerError([
|
|
147
|
+
`<$red:Security Error:$> Absolute import paths are not allowed: <$magenta:${argValue}$>{line}`,
|
|
148
|
+
`<$yellow:Use a path relative to the template file, e.g.$> <$green:SomMark.import("./data.json")$> <$yellow:or$> <$green:SomMark.import("../shared/data.json")$><$yellow:.$>{line}`,
|
|
149
|
+
`<$yellow:Base directory:$> <$blue:${baseDir}$>{line}`
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
152
|
+
|
|
143
153
|
const resolvedPath = path.resolve(baseDir, argValue);
|
|
144
154
|
|
|
155
|
+
// Block path traversal — resolved path must stay inside baseDir
|
|
156
|
+
const safeBases = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep;
|
|
157
|
+
if (!resolvedPath.startsWith(safeBases) && resolvedPath !== baseDir) {
|
|
158
|
+
transpilerError([
|
|
159
|
+
`<$red:Security Error:$> Import path escapes the project directory: <$magenta:${argValue}$>{line}`,
|
|
160
|
+
`<$yellow:Resolved Path:$> <$blue:${resolvedPath}$>{line}`
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
|
|
145
164
|
const fsImpl = instance?.fs || await getNodeFs();
|
|
146
165
|
|
|
147
166
|
// File presence validation
|
package/core/modules.js
CHANGED
|
@@ -192,34 +192,48 @@ export async function resolveModules(ast, context) {
|
|
|
192
192
|
const baseDir = context.instance.baseDir || ((filename === "anonymous") ? absFilename : path.dirname(absFilename));
|
|
193
193
|
|
|
194
194
|
// 1. Helper: Trim AST to remove file-boundary whitespace and "ghost" newlines
|
|
195
|
-
const trimAst = (nodes) => {
|
|
195
|
+
const trimAst = (nodes, trimBoundaries = true) => {
|
|
196
196
|
if (!nodes) return [];
|
|
197
197
|
|
|
198
|
-
// 1. Filter out
|
|
199
|
-
// (Comments, Imports,
|
|
198
|
+
// 1. Filter out whitespace-only text nodes adjacent (directly or through other whitespace)
|
|
199
|
+
// to non-rendering nodes (Comments, Imports, USE_MODULE).
|
|
200
200
|
const nonRenderingTypes = [COMMENT, IMPORT, USE_MODULE];
|
|
201
201
|
let res = nodes.filter((node, idx) => {
|
|
202
202
|
if (node.type !== TEXT || node.text.trim() !== "") return true;
|
|
203
203
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
(
|
|
208
|
-
|
|
204
|
+
// Walk backwards through consecutive whitespace nodes to find prev non-whitespace
|
|
205
|
+
let prevIsNonRendering = false;
|
|
206
|
+
for (let j = idx - 1; j >= 0; j--) {
|
|
207
|
+
if (nodes[j].type === TEXT && nodes[j].text.trim() === "") continue;
|
|
208
|
+
prevIsNonRendering = nonRenderingTypes.includes(nodes[j].type);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Walk forwards through consecutive whitespace nodes to find next non-whitespace
|
|
213
|
+
let nextIsNonRendering = false;
|
|
214
|
+
for (let j = idx + 1; j < nodes.length; j++) {
|
|
215
|
+
if (nodes[j].type === TEXT && nodes[j].text.trim() === "") continue;
|
|
216
|
+
nextIsNonRendering = nonRenderingTypes.includes(nodes[j].type);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
209
219
|
|
|
210
|
-
return !
|
|
220
|
+
return !(prevIsNonRendering || nextIsNonRendering);
|
|
211
221
|
});
|
|
212
222
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
res
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
res
|
|
223
|
+
if (trimBoundaries) {
|
|
224
|
+
// 2. Final pass: trim leading/trailing newlines from the remaining boundary text nodes
|
|
225
|
+
if (res.length > 0 && res[0].type === TEXT) {
|
|
226
|
+
res[0].text = res[0].text.replace(/^[\r\n]+/, "");
|
|
227
|
+
}
|
|
228
|
+
if (res.length > 0 && res[res.length - 1].type === TEXT) {
|
|
229
|
+
res[res.length - 1].text = res[res.length - 1].text.replace(/[\r\n]+\s*$/, "");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 3. Remove any nodes that became purely empty after trimming
|
|
233
|
+
res = res.filter(node => node.type !== TEXT || node.text !== "");
|
|
219
234
|
}
|
|
220
235
|
|
|
221
|
-
|
|
222
|
-
return res.filter(node => node.type !== TEXT || node.text !== "");
|
|
236
|
+
return res;
|
|
223
237
|
};
|
|
224
238
|
|
|
225
239
|
// 2. Helper: Inject Slots with Indentation Propagation
|
|
@@ -282,13 +296,38 @@ export async function resolveModules(ast, context) {
|
|
|
282
296
|
let resolvedPath = filePath;
|
|
283
297
|
for (const [prefix, replacement] of Object.entries(importAliases)) {
|
|
284
298
|
if (filePath.startsWith(prefix)) {
|
|
285
|
-
|
|
299
|
+
const replaced = filePath.replace(prefix, replacement);
|
|
300
|
+
// Preserve scheme prefixes (pkg:, http:, etc.) — don't path.resolve them
|
|
301
|
+
resolvedPath = replaced.startsWith("pkg:") || replaced.startsWith("http://") || replaced.startsWith("https://")
|
|
302
|
+
? replaced
|
|
303
|
+
: path.resolve(context.instance.cwd || "/", replaced);
|
|
286
304
|
break;
|
|
287
305
|
}
|
|
288
306
|
}
|
|
289
307
|
|
|
290
|
-
// 1b.
|
|
291
|
-
|
|
308
|
+
// 1b. pkg: — resolve from node_modules at project root
|
|
309
|
+
let absolutePath;
|
|
310
|
+
if (resolvedPath.startsWith("pkg:")) {
|
|
311
|
+
if (!context.instance.fs?.__isNodeFs) {
|
|
312
|
+
runtimeError([`<$red:Module Error:$> <$cyan:pkg:$> imports are not supported in browser or virtual filesystem mode at line <$yellow:${node.range.start.line + 1}$>`]);
|
|
313
|
+
}
|
|
314
|
+
const pkgPath = resolvedPath.slice(4);
|
|
315
|
+
if (!pkgPath || pkgPath.trim() === "") {
|
|
316
|
+
runtimeError([`<$red:Module Error:$> <$cyan:pkg:$> path cannot be empty at line <$yellow:${node.range.start.line + 1}$>`]);
|
|
317
|
+
}
|
|
318
|
+
const nodeModulesRoot = path.resolve(context.instance.cwd || "/", "node_modules");
|
|
319
|
+
absolutePath = path.resolve(nodeModulesRoot, pkgPath);
|
|
320
|
+
if (!absolutePath.startsWith(nodeModulesRoot + path.sep) && absolutePath !== nodeModulesRoot) {
|
|
321
|
+
runtimeError([`<$red:Module Security Error:$> <$cyan:pkg:${pkgPath}$> resolves outside node_modules — path traversal is not allowed at line <$yellow:${node.range.start.line + 1}$>`]);
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
// 1c. Resolve relative to current base (FS)
|
|
325
|
+
absolutePath = resolveModulePath(resolvedPath, currentBaseDir);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!context.instance.fs) {
|
|
329
|
+
runtimeError([`<$red:Module Error:$> Cannot import <$magenta:${filePath}$> — no filesystem is available.{N}In browser mode, pass a URL-based <$cyan:baseDir$> or a <$cyan:files$> map to enable module loading.`]);
|
|
330
|
+
}
|
|
292
331
|
|
|
293
332
|
// Local Path Resolution with Auto-Extension
|
|
294
333
|
let localPath = absolutePath;
|
|
@@ -441,7 +480,6 @@ export async function resolveModules(ast, context) {
|
|
|
441
480
|
Object.entries(node.props).filter(([key]) => {
|
|
442
481
|
if (key === "__consumed__") return false;
|
|
443
482
|
if (consumed.has(key)) return false; // THE FIX: Filter if hit by v{}
|
|
444
|
-
if (key === "smark-raw") return false; // directive — must not leak onto root element
|
|
445
483
|
return true;
|
|
446
484
|
})
|
|
447
485
|
);
|
package/core/parser.js
CHANGED
|
@@ -96,6 +96,7 @@ function makeBlockNode() {
|
|
|
96
96
|
structure: "Block",
|
|
97
97
|
id: "",
|
|
98
98
|
props: {},
|
|
99
|
+
directives: {},
|
|
99
100
|
body: [],
|
|
100
101
|
depth: 0,
|
|
101
102
|
range: {
|
|
@@ -633,9 +634,11 @@ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {
|
|
|
633
634
|
i = valueIndex;
|
|
634
635
|
|
|
635
636
|
// Store Argument
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
637
|
+
if (k && k.startsWith("smark-")) {
|
|
638
|
+
blockNode.directives[k.slice(6)] = v; // strip "smark-" prefix
|
|
639
|
+
} else {
|
|
640
|
+
blockNode.props[String(argIndex++)] = v;
|
|
641
|
+
if (k) blockNode.props[k] = v;
|
|
639
642
|
}
|
|
640
643
|
k = "";
|
|
641
644
|
v = "";
|