sommark 5.0.5 → 5.1.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 +83 -13
- 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 +35 -18
- package/core/parser.js +6 -3
- package/core/transpiler.js +30 -10
- package/core/validator.js +17 -4
- package/dist/sommark.browser.js +414 -196
- package/dist/sommark.browser.lite.js +331 -182
- package/dist/sommark.parser.js +6 -3
- package/esbuild.js +64 -0
- package/index.browser.js +4 -1
- package/index.js +32 -1
- package/index.shared.js +10 -1
- package/mappers/languages/markdown.js +99 -81
- package/mappers/languages/xml.js +76 -61
- package/mappers/shared/index.js +10 -1
- package/package.json +9 -2
- 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,22 @@
|
|
|
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";
|
|
7
6
|
|
|
8
|
-
//
|
|
9
|
-
|
|
7
|
+
// Set by index.js (Node.js) or index.browser.js (shim) — never imported directly.
|
|
8
|
+
let evaluatorStorage = null;
|
|
9
|
+
|
|
10
|
+
export function setDefaultAsyncLocalStorage(cls) {
|
|
11
|
+
evaluatorStorage = cls ? new cls() : null;
|
|
12
|
+
}
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* Runs fn inside an isolated evaluator context.
|
|
13
16
|
* Concurrent transpile() calls each get their own stack — no cross-contamination.
|
|
14
17
|
*/
|
|
15
18
|
export function withEvaluator(fn) {
|
|
19
|
+
if (!evaluatorStorage) return fn();
|
|
16
20
|
return evaluatorStorage.run([], fn);
|
|
17
21
|
}
|
|
18
22
|
|
|
@@ -170,6 +174,7 @@ const customCompileAdapter = async (src, options, parentSecurity = {}) => {
|
|
|
170
174
|
registerHostCompile(customCompileAdapter);
|
|
171
175
|
|
|
172
176
|
let defaultFs = null;
|
|
177
|
+
let defaultEnv = null;
|
|
173
178
|
let quickJSInstance = null;
|
|
174
179
|
async function getQuickJSModule() {
|
|
175
180
|
if (!quickJSInstance) {
|
|
@@ -193,6 +198,16 @@ function objectToHandle(context, obj) {
|
|
|
193
198
|
return result.unwrap();
|
|
194
199
|
}
|
|
195
200
|
|
|
201
|
+
function isPlainData(value, seen = new Set()) {
|
|
202
|
+
if (value === null || value === undefined) return true;
|
|
203
|
+
if (typeof value === "function") return false;
|
|
204
|
+
if (typeof value !== "object") return true;
|
|
205
|
+
if (seen.has(value)) return false;
|
|
206
|
+
seen.add(value);
|
|
207
|
+
if (Array.isArray(value)) return value.every(v => isPlainData(v, seen));
|
|
208
|
+
return Object.values(value).every(v => isPlainData(v, seen));
|
|
209
|
+
}
|
|
210
|
+
|
|
196
211
|
function expose(context, vars, pendingDeferreds) {
|
|
197
212
|
for (const [key, value] of Object.entries(vars)) {
|
|
198
213
|
let handle;
|
|
@@ -313,6 +328,18 @@ class EvaluatorState {
|
|
|
313
328
|
});
|
|
314
329
|
|
|
315
330
|
this.expose({
|
|
331
|
+
__hostEnv: (key) => {
|
|
332
|
+
if (defaultEnv === null) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
"[SomMark] SomMark.env() is not available in browser mode.\n" +
|
|
335
|
+
"Environment variables are a server-side concept.\n" +
|
|
336
|
+
"Read env values at build time and pass them as placeholders instead."
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
const allowlist = this.security?.env;
|
|
340
|
+
if (!Array.isArray(allowlist) || !allowlist.includes(key)) return undefined;
|
|
341
|
+
return defaultEnv[key] ?? undefined;
|
|
342
|
+
},
|
|
316
343
|
__hostSomMarkVersion: SomMark.version,
|
|
317
344
|
__hostSomMarkSettings: () => {
|
|
318
345
|
const clean = { ...SomMark.settings };
|
|
@@ -524,6 +551,12 @@ class EvaluatorState {
|
|
|
524
551
|
throw new Error("SomMark.static Error: Argument must be a string.");
|
|
525
552
|
}
|
|
526
553
|
return globalThis.eval(expr);
|
|
554
|
+
},
|
|
555
|
+
env: (key) => {
|
|
556
|
+
if (typeof key !== "string" || !key) {
|
|
557
|
+
throw new Error("SomMark.env Error: Key must be a non-empty string.");
|
|
558
|
+
}
|
|
559
|
+
return __hostEnv(key);
|
|
527
560
|
}
|
|
528
561
|
};
|
|
529
562
|
|
|
@@ -546,15 +579,20 @@ class EvaluatorState {
|
|
|
546
579
|
}
|
|
547
580
|
setupRes.value.dispose();
|
|
548
581
|
|
|
549
|
-
// Configure module loader using virtual FS implementation
|
|
582
|
+
// Configure module loader using virtual FS implementation.
|
|
583
|
+
// The normalizer resolves every import to an absolute path so the module
|
|
584
|
+
// cache key is always absolute — <smark> (the eval module name) can never
|
|
585
|
+
// be reached by any user import regardless of what the file is named.
|
|
550
586
|
this.runtime.setModuleLoader((moduleName) => {
|
|
551
587
|
try {
|
|
552
588
|
const isRaw = moduleName.endsWith("?raw");
|
|
553
589
|
const cleanModuleName = isRaw ? moduleName.slice(0, -4) : moduleName;
|
|
554
|
-
|
|
555
|
-
|
|
590
|
+
// moduleName is already an absolute path (supplied by the normalizer below),
|
|
591
|
+
// so resolve() is a no-op for absolute paths and a safe fallback for URLs.
|
|
592
|
+
const resolvedPath = /^https?:\/\//.test(cleanModuleName)
|
|
593
|
+
? cleanModuleName
|
|
556
594
|
: path.resolve(this.baseDir, cleanModuleName);
|
|
557
|
-
|
|
595
|
+
|
|
558
596
|
const fsImpl = this.settings?.fs || this.settings?.instance?.fs || this.nodeFs;
|
|
559
597
|
if (!fsImpl) {
|
|
560
598
|
throw new Error("No filesystem implementation available.");
|
|
@@ -587,6 +625,22 @@ class EvaluatorState {
|
|
|
587
625
|
} catch (err) {
|
|
588
626
|
throw err;
|
|
589
627
|
}
|
|
628
|
+
}, (baseName, moduleName) => {
|
|
629
|
+
// Resolve every import to an absolute path so no user import can ever
|
|
630
|
+
// normalize to <smark> (or any other virtual eval module name).
|
|
631
|
+
const isRaw = moduleName.endsWith("?raw");
|
|
632
|
+
const clean = isRaw ? moduleName.slice(0, -4) : moduleName;
|
|
633
|
+
if (/^https?:\/\//.test(clean)) return moduleName;
|
|
634
|
+
const baseDir = (baseName === "<smark>" || !path.isAbsolute(baseName))
|
|
635
|
+
? this.baseDir
|
|
636
|
+
: (/^https?:\/\//.test(baseName) ? baseName : path.dirname(baseName));
|
|
637
|
+
let resolved;
|
|
638
|
+
if (/^https?:\/\//.test(baseDir)) {
|
|
639
|
+
resolved = new URL(clean, baseDir).href;
|
|
640
|
+
} else {
|
|
641
|
+
resolved = path.resolve(baseDir, clean);
|
|
642
|
+
}
|
|
643
|
+
return isRaw ? resolved + "?raw" : resolved;
|
|
590
644
|
});
|
|
591
645
|
}
|
|
592
646
|
|
|
@@ -737,9 +791,17 @@ class EvaluatorState {
|
|
|
737
791
|
|
|
738
792
|
inject(vars) {
|
|
739
793
|
if (!this.context) return;
|
|
794
|
+
const safe = {};
|
|
795
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
796
|
+
if (!isPlainData(value)) {
|
|
797
|
+
console.warn(`[SomMark] Security: "${key}" contains functions and was blocked. Only plain data can be injected. Use SomMark built-ins for host capabilities.`);
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
safe[key] = value;
|
|
801
|
+
}
|
|
740
802
|
const currentScope = this.scopes[this.scopes.length - 1];
|
|
741
|
-
Object.assign(currentScope,
|
|
742
|
-
this.expose(
|
|
803
|
+
Object.assign(currentScope, safe);
|
|
804
|
+
this.expose(safe);
|
|
743
805
|
}
|
|
744
806
|
|
|
745
807
|
async execute(code, baseDir = null) {
|
|
@@ -823,7 +885,7 @@ class EvaluatorState {
|
|
|
823
885
|
|
|
824
886
|
let result;
|
|
825
887
|
if (isModule) {
|
|
826
|
-
const evalRes = this.context.evalCode(finalCode, "
|
|
888
|
+
const evalRes = this.context.evalCode(finalCode, "<smark>", { type: 'module' });
|
|
827
889
|
if (evalRes.error) {
|
|
828
890
|
const err = this.context.dump(evalRes.error);
|
|
829
891
|
evalRes.error.dispose();
|
|
@@ -892,7 +954,7 @@ class EvaluatorState {
|
|
|
892
954
|
}
|
|
893
955
|
|
|
894
956
|
const defaultValue = this.context.dump(resolvedDefaultHandle);
|
|
895
|
-
|
|
957
|
+
|
|
896
958
|
if (isPromise) {
|
|
897
959
|
resolvedDefaultHandle.dispose();
|
|
898
960
|
}
|
|
@@ -932,7 +994,7 @@ class EvaluatorState {
|
|
|
932
994
|
result = res;
|
|
933
995
|
}
|
|
934
996
|
} else {
|
|
935
|
-
const evalRes = this.context.evalCode(code, "
|
|
997
|
+
const evalRes = this.context.evalCode(code, "<smark>");
|
|
936
998
|
if (evalRes.error) {
|
|
937
999
|
const err = this.context.dump(evalRes.error);
|
|
938
1000
|
evalRes.error.dispose();
|
|
@@ -968,7 +1030,7 @@ class EvaluatorState {
|
|
|
968
1030
|
return result;
|
|
969
1031
|
} catch (error) {
|
|
970
1032
|
const stack = error.stack || "";
|
|
971
|
-
const match = stack.match(/
|
|
1033
|
+
const match = stack.match(/__smark__\.js:(\d+):(\d+)/) || stack.match(/:(\d+):(\d+)/);
|
|
972
1034
|
|
|
973
1035
|
const err = new Error(error.message || error);
|
|
974
1036
|
if (match) {
|
|
@@ -1031,6 +1093,14 @@ class Evaluator {
|
|
|
1031
1093
|
defaultFs = fs;
|
|
1032
1094
|
}
|
|
1033
1095
|
|
|
1096
|
+
setDefaultEnv(env) {
|
|
1097
|
+
defaultEnv = env;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
setDefaultAsyncLocalStorage(cls) {
|
|
1101
|
+
setDefaultAsyncLocalStorage(cls);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1034
1104
|
get active() {
|
|
1035
1105
|
const stack = this._getStack();
|
|
1036
1106
|
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
|
|
@@ -290,6 +304,10 @@ export async function resolveModules(ast, context) {
|
|
|
290
304
|
// 1b. Resolve relative to current base (FS)
|
|
291
305
|
const absolutePath = resolveModulePath(resolvedPath, currentBaseDir);
|
|
292
306
|
|
|
307
|
+
if (!context.instance.fs) {
|
|
308
|
+
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.`]);
|
|
309
|
+
}
|
|
310
|
+
|
|
293
311
|
// Local Path Resolution with Auto-Extension
|
|
294
312
|
let localPath = absolutePath;
|
|
295
313
|
if (!await context.instance.fs.exists(localPath) && !localPath.endsWith(".smark")) {
|
|
@@ -441,7 +459,6 @@ export async function resolveModules(ast, context) {
|
|
|
441
459
|
Object.entries(node.props).filter(([key]) => {
|
|
442
460
|
if (key === "__consumed__") return false;
|
|
443
461
|
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
462
|
return true;
|
|
446
463
|
})
|
|
447
464
|
);
|
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 = "";
|
package/core/transpiler.js
CHANGED
|
@@ -31,6 +31,7 @@ const randomBytesHex = (size) => {
|
|
|
31
31
|
|
|
32
32
|
const BODY_PLACEHOLDER = `SOMMARKBODYPLACEHOLDER${randomBytesHex(8)}SOMMARK`;
|
|
33
33
|
|
|
34
|
+
|
|
34
35
|
/**
|
|
35
36
|
* Extracts all plain text from a node and its children.
|
|
36
37
|
*
|
|
@@ -129,6 +130,17 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
|
|
|
129
130
|
|
|
130
131
|
if (node.type === FOR_EACH) {
|
|
131
132
|
const transpiledArgs = await transpileArgs(node.props);
|
|
133
|
+
|
|
134
|
+
if (!node.props || (node.props[0] === undefined && node.props["items"] === undefined)) {
|
|
135
|
+
const line = node.range?.start?.line + 1 || 1;
|
|
136
|
+
transpilerError([
|
|
137
|
+
`<$red:Missing Prop Error in [for-each]:$>{line}`,
|
|
138
|
+
`[for-each] requires an array as its first prop, e.g. [for-each = \${ array }\$]{line}`,
|
|
139
|
+
`at line <$yellow:${line}$>{line}`
|
|
140
|
+
]);
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
|
|
132
144
|
const items = mapper_file ? mapper_file.safeArg({ props: transpiledArgs, index: 0, key: "items", fallBack: [] }) : [];
|
|
133
145
|
|
|
134
146
|
if (!Array.isArray(items)) {
|
|
@@ -142,11 +154,11 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
|
|
|
142
154
|
}
|
|
143
155
|
|
|
144
156
|
const asVar = transpiledArgs.as || "value";
|
|
145
|
-
if (asVar === "i") {
|
|
157
|
+
if (asVar === "i" || asVar === "length") {
|
|
146
158
|
const line = node.range?.start?.line + 1 || 1;
|
|
147
159
|
transpilerError([
|
|
148
160
|
`<$red:Reserved Variable Error in [for-each]:$>{line}`,
|
|
149
|
-
`'
|
|
161
|
+
`'${asVar}' is a reserved variable name.{N}Use a different name for the 'as' prop, e.g. as: "item"{line}`,
|
|
150
162
|
`at line <$yellow:${line}$>{line}`
|
|
151
163
|
]);
|
|
152
164
|
return "";
|
|
@@ -176,22 +188,28 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
|
|
|
176
188
|
}
|
|
177
189
|
}
|
|
178
190
|
|
|
179
|
-
|
|
191
|
+
const rawJoin = transpiledArgs.join ?? null;
|
|
192
|
+
const join = rawJoin !== null ? rawJoin.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\r/g, "\r") : null;
|
|
193
|
+
const parts = [];
|
|
180
194
|
let idx = 0;
|
|
195
|
+
const length = items.length;
|
|
181
196
|
for (const item of items) {
|
|
182
197
|
evaluator.pushScope();
|
|
183
198
|
evaluator.inject({
|
|
184
199
|
[asVar]: item,
|
|
185
|
-
i: idx
|
|
200
|
+
i: idx++,
|
|
201
|
+
length
|
|
186
202
|
});
|
|
187
203
|
|
|
204
|
+
let iterOutput = "";
|
|
188
205
|
for (let j = 0; j < cleanedBody.length; j++) {
|
|
189
|
-
|
|
206
|
+
iterOutput += await generateOutput(cleanedBody, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState, extraCtx);
|
|
190
207
|
}
|
|
191
208
|
|
|
192
209
|
await evaluator.popScope();
|
|
210
|
+
parts.push(iterOutput);
|
|
193
211
|
}
|
|
194
|
-
return
|
|
212
|
+
return join !== null ? parts.join(join) : parts.join("");
|
|
195
213
|
}
|
|
196
214
|
|
|
197
215
|
let secretId = null;
|
|
@@ -219,13 +237,12 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
|
|
|
219
237
|
}
|
|
220
238
|
|
|
221
239
|
// smark-raw block — body collected verbatim by lexer, bypasses normal body processing pipeline
|
|
222
|
-
if (node.type === BLOCK && (node.
|
|
240
|
+
if (node.type === BLOCK && (node.directives?.raw === "true" || node.directives?.raw === true)) {
|
|
223
241
|
if (generateRuntimeOutput) return "";
|
|
224
242
|
const rawContent = node.body?.map(n => String(n.text || "")).join("") || "";
|
|
225
|
-
const
|
|
226
|
-
const transpiledArgs = await transpileArgs(cleanArgs);
|
|
243
|
+
const transpiledArgs = await transpileArgs(node.props);
|
|
227
244
|
if (evaluator.active?.hasDynamicTag?.(node.id)) {
|
|
228
|
-
return await evaluator.active.executeDynamicTag(node.id, { props: transpiledArgs, content: rawContent, textContent: rawContent });
|
|
245
|
+
return await evaluator.active.executeDynamicTag(node.id, { props: transpiledArgs, directives: node.directives, content: rawContent, textContent: rawContent });
|
|
229
246
|
}
|
|
230
247
|
let rawTarget = mapper_file ? matchedValue(mapper_file.outputs, node.id) : null;
|
|
231
248
|
if (!rawTarget && mapper_file) rawTarget = mapper_file.getUnknownTag(node);
|
|
@@ -233,6 +250,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
|
|
|
233
250
|
const isManualMode = !!rawTarget.options?.handleAst;
|
|
234
251
|
return await rawTarget.render.call(mapper_file, {
|
|
235
252
|
props: transpiledArgs,
|
|
253
|
+
directives: node.directives,
|
|
236
254
|
content: rawContent,
|
|
237
255
|
textContent: rawContent,
|
|
238
256
|
ast: isManualMode ? node : undefined,
|
|
@@ -344,6 +362,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
|
|
|
344
362
|
|
|
345
363
|
return await target.render.call(mapper_file, {
|
|
346
364
|
props: transpiledArgs,
|
|
365
|
+
directives: node.directives,
|
|
347
366
|
content: "",
|
|
348
367
|
textContent: richText || textContent,
|
|
349
368
|
ast: cleanAst,
|
|
@@ -362,6 +381,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
|
|
|
362
381
|
}
|
|
363
382
|
result += await target.render.call(mapper_file, {
|
|
364
383
|
props: transpiledArgs,
|
|
384
|
+
directives: node.directives,
|
|
365
385
|
content,
|
|
366
386
|
textContent,
|
|
367
387
|
ast: new Proxy({}, {
|
package/core/validator.js
CHANGED
|
@@ -55,10 +55,7 @@ const runValidations = (node, target, instance) => {
|
|
|
55
55
|
const isStructural = node.type === "Block";
|
|
56
56
|
if (isStructural && rules.required_args && Array.isArray(rules.required_args)) {
|
|
57
57
|
const missingArgs = rules.required_args.filter(arg => {
|
|
58
|
-
|
|
59
|
-
if (typeof arg === "number") {
|
|
60
|
-
return node.props[arg] === undefined;
|
|
61
|
-
}
|
|
58
|
+
if (typeof arg === "number") return node.props[arg] === undefined;
|
|
62
59
|
return node.props[arg] === undefined;
|
|
63
60
|
});
|
|
64
61
|
|
|
@@ -73,6 +70,22 @@ const runValidations = (node, target, instance) => {
|
|
|
73
70
|
);
|
|
74
71
|
}
|
|
75
72
|
}
|
|
73
|
+
|
|
74
|
+
// -- Directives Validation (Required Directives) ----------------------- //
|
|
75
|
+
if (isStructural && rules.required_directives && Array.isArray(rules.required_directives)) {
|
|
76
|
+
const missingDirectives = rules.required_directives.filter(key => node.directives?.[key] === undefined);
|
|
77
|
+
|
|
78
|
+
if (missingDirectives.length > 0) {
|
|
79
|
+
transpilerError(
|
|
80
|
+
[
|
|
81
|
+
"{N}",
|
|
82
|
+
`<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:is missing required directive props:$> <$red:${missingDirectives.map(k => `smark-${k}`).join(", ")}$>{N}`,
|
|
83
|
+
`<$blue:Please ensure these directive props are provided in the template usage.$>`
|
|
84
|
+
],
|
|
85
|
+
context
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
76
89
|
};
|
|
77
90
|
|
|
78
91
|
/**
|