sommark 4.0.2 → 4.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/README.md +274 -73
- package/cli/cli.mjs +2 -2
- package/cli/commands/build.js +3 -1
- package/cli/commands/help.js +4 -1
- package/cli/commands/init.js +25 -6
- package/cli/commands/show.js +20 -10
- package/cli/constants.js +2 -1
- package/cli/helpers/transpile.js +5 -2
- package/constants/html_props.js +1 -0
- package/core/evaluator.js +785 -0
- package/core/formats.js +15 -7
- package/core/helpers/config-loader.js +28 -15
- package/core/helpers/lib.js +75 -0
- package/core/helpers/preprocessor.js +185 -0
- package/core/helpers/runtimeOutput.js +28 -0
- package/core/labels.js +9 -2
- package/core/lexer.js +228 -61
- package/core/modules.js +331 -55
- package/core/parser.js +275 -55
- package/core/tokenTypes.js +11 -0
- package/core/transpiler.js +341 -59
- package/core/validator.js +85 -13
- package/formatter/tag.js +31 -7
- package/grammar.ebnf +21 -10
- package/helpers/safeDataParser.js +3 -3
- package/helpers/spinner.js +91 -0
- package/helpers/utils.js +46 -0
- package/index.js +125 -38
- package/mappers/languages/html.js +50 -9
- package/mappers/languages/json.js +81 -38
- package/mappers/languages/jsonc.js +82 -0
- package/mappers/languages/markdown.js +88 -48
- package/mappers/languages/mdx.js +50 -15
- package/mappers/languages/text.js +67 -0
- package/mappers/languages/xml.js +6 -6
- package/mappers/mapper.js +36 -4
- package/mappers/shared/index.js +12 -13
- package/package.json +6 -1
- package/core/formatter.js +0 -215
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
import { quickJS } from "@sebastianwessel/quickjs";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import * as acorn from "acorn";
|
|
5
|
+
import SomMark, { registerHostCompile, registerHostSettings } from "./helpers/lib.js";
|
|
6
|
+
|
|
7
|
+
// Global tracker to ensure deep recursive Smark compilation never exceeds safe boundaries
|
|
8
|
+
let globalCompilationDepth = 0;
|
|
9
|
+
|
|
10
|
+
// Pure, top-level stateless adapters to avoid circular references and closures over EvaluatorState
|
|
11
|
+
const customFetchAdapter = async (input, init, security = {}) => {
|
|
12
|
+
const allowFetch = security?.allowFetch !== false;
|
|
13
|
+
if (!allowFetch) {
|
|
14
|
+
throw new Error("Fetch Error: fetch is disabled in this environment.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const url = input.toString();
|
|
18
|
+
try {
|
|
19
|
+
const parsedUrl = new URL(url);
|
|
20
|
+
const protocol = parsedUrl.protocol.toLowerCase();
|
|
21
|
+
const hostname = parsedUrl.hostname.toLowerCase();
|
|
22
|
+
|
|
23
|
+
// 1. Enforce HTTPS (HTTP Blocked by default unless allowHttp is true)
|
|
24
|
+
const allowHttp = security?.allowHttp === true;
|
|
25
|
+
if (protocol === "http:" && !allowHttp) {
|
|
26
|
+
throw new Error("Fetch Security Error: HTTP requests are disabled. Use HTTPS instead.");
|
|
27
|
+
}
|
|
28
|
+
if (protocol !== "http:" && protocol !== "https:") {
|
|
29
|
+
throw new Error(`Fetch Security Error: Unsupported protocol '${protocol}'. Only HTTP/HTTPS is allowed.`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 2. SSRF Protection: Block localhost, loopbacks, link-local, and RFC 1918 private network IP ranges
|
|
33
|
+
const isLocal = hostname === "localhost" ||
|
|
34
|
+
hostname === "127.0.0.1" ||
|
|
35
|
+
hostname === "0.0.0.0" ||
|
|
36
|
+
hostname === "[::1]" ||
|
|
37
|
+
hostname === "::" ||
|
|
38
|
+
hostname.startsWith("127.") ||
|
|
39
|
+
hostname.startsWith("10.") ||
|
|
40
|
+
hostname.startsWith("192.168.") ||
|
|
41
|
+
hostname.startsWith("169.254.") ||
|
|
42
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname);
|
|
43
|
+
|
|
44
|
+
if (isLocal) {
|
|
45
|
+
throw new Error("SSRF Protection: Requests to local or private IP addresses are forbidden.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. Whitelisted Origins Check
|
|
49
|
+
const allowedOrigins = security?.allowedOrigins;
|
|
50
|
+
if (allowedOrigins && allowedOrigins.length > 0) {
|
|
51
|
+
const origin = parsedUrl.origin.toLowerCase();
|
|
52
|
+
const isOriginAllowed = allowedOrigins.some(allowed => {
|
|
53
|
+
try {
|
|
54
|
+
const allowedUrl = new URL(allowed);
|
|
55
|
+
return origin === allowedUrl.origin.toLowerCase();
|
|
56
|
+
} catch {
|
|
57
|
+
return hostname === allowed.toLowerCase() || hostname.endsWith("." + allowed.toLowerCase());
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
if (!isOriginAllowed) {
|
|
61
|
+
throw new Error(`Fetch Security Error: Origin '${origin}' is not whitelisted.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 4. Whitelisted Extensions Check
|
|
66
|
+
const allowedExtensions = security?.allowedExtensions;
|
|
67
|
+
if (allowedExtensions && allowedExtensions.length > 0) {
|
|
68
|
+
const ext = path.extname(parsedUrl.pathname).toLowerCase();
|
|
69
|
+
if (!allowedExtensions.includes(ext)) {
|
|
70
|
+
throw new Error(`Fetch Security Error: Extension '${ext || "(none)"}' is not whitelisted.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (e) {
|
|
74
|
+
throw new Error(e.message.startsWith("Fetch Security Error:") || e.message.startsWith("SSRF Protection:")
|
|
75
|
+
? e.message
|
|
76
|
+
: "Fetch Security Error: " + e.message);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const res = await fetch(url, init);
|
|
80
|
+
const bodyText = await res.text();
|
|
81
|
+
|
|
82
|
+
const headers = {};
|
|
83
|
+
res.headers.forEach((val, key) => {
|
|
84
|
+
headers[key.toLowerCase()] = val;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
status: res.status,
|
|
89
|
+
ok: res.ok,
|
|
90
|
+
statusText: res.statusText,
|
|
91
|
+
url: res.url,
|
|
92
|
+
type: res.type,
|
|
93
|
+
redirected: res.redirected,
|
|
94
|
+
bodyText,
|
|
95
|
+
headers
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const customCompileAdapter = async (src, options, parentSecurity = {}) => {
|
|
100
|
+
const maxDepth = parentSecurity?.maxDepth ?? 5;
|
|
101
|
+
if (globalCompilationDepth >= maxDepth) {
|
|
102
|
+
throw new Error(`Recursion Guard: Maximum Smark compilation depth exceeded (limit is ${maxDepth}).`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
globalCompilationDepth++;
|
|
106
|
+
try {
|
|
107
|
+
// Securely isolate and deep-clone options to strip parent VM proxies
|
|
108
|
+
const cleanOptions = JSON.parse(JSON.stringify(options || {}));
|
|
109
|
+
const { default: SomMarkCompiler } = await import("../index.js");
|
|
110
|
+
const compilerOptions = {
|
|
111
|
+
src,
|
|
112
|
+
format: cleanOptions.format || "html",
|
|
113
|
+
variables: cleanOptions.variables || {},
|
|
114
|
+
formatOption: cleanOptions.formatOption || {},
|
|
115
|
+
security: parentSecurity
|
|
116
|
+
};
|
|
117
|
+
const sm = new SomMarkCompiler(compilerOptions);
|
|
118
|
+
return await sm.transpile();
|
|
119
|
+
} finally {
|
|
120
|
+
globalCompilationDepth--;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Register statically once at module loading
|
|
125
|
+
registerHostCompile(customCompileAdapter);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* EvaluatorState
|
|
129
|
+
*
|
|
130
|
+
* Houses the actual state, scopes, and QuickJS VM instance for a single transpilation lifecycle.
|
|
131
|
+
*/
|
|
132
|
+
class EvaluatorState {
|
|
133
|
+
constructor() {
|
|
134
|
+
this.runtime = null;
|
|
135
|
+
this.baseDir = process.cwd();
|
|
136
|
+
this.scopes = [{}];
|
|
137
|
+
this.dynamicTagsStack = [new Map()];
|
|
138
|
+
this.deadline = 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Initializes the QuickJS VM.
|
|
143
|
+
*/
|
|
144
|
+
async init(baseDir = null, security = {}, settings = {}, mapperFile = null) {
|
|
145
|
+
if (baseDir) this.baseDir = baseDir;
|
|
146
|
+
this.scopes = [{}];
|
|
147
|
+
this.dynamicTagsStack = [new Map()];
|
|
148
|
+
this.security = security;
|
|
149
|
+
this.settings = settings;
|
|
150
|
+
this.mapperFile = mapperFile;
|
|
151
|
+
registerHostSettings(settings);
|
|
152
|
+
|
|
153
|
+
if (this.runtime) {
|
|
154
|
+
this.runtime.vm.expose({
|
|
155
|
+
__allowRaw: this.security.allowRaw !== false
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { createRuntime } = await quickJS();
|
|
161
|
+
|
|
162
|
+
this.runtime = await createRuntime({
|
|
163
|
+
allowFetch: true,
|
|
164
|
+
fetchAdapter: async (input, init) => {
|
|
165
|
+
return await customFetchAdapter(input, init, this.security);
|
|
166
|
+
},
|
|
167
|
+
allowFs: true,
|
|
168
|
+
env: {}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
this.deadline = 0;
|
|
172
|
+
if (this.runtime?.vm?.context?.runtime?.setInterruptHandler) {
|
|
173
|
+
this.runtime.vm.context.runtime.setInterruptHandler(() => {
|
|
174
|
+
return this.deadline > 0 && Date.now() > this.deadline;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Expose standard library version & compile adapter, then construct the frozen global namespace inside the VM
|
|
179
|
+
this.runtime.vm.expose({
|
|
180
|
+
__hostSomMarkVersion: SomMark.version,
|
|
181
|
+
__hostSomMarkSettings: () => JSON.stringify(SomMark.settings),
|
|
182
|
+
__hostCompile: async (src, options) => {
|
|
183
|
+
return await customCompileAdapter(src, options, this.security);
|
|
184
|
+
},
|
|
185
|
+
__hostRegisterDynamicTag: (id, options) => {
|
|
186
|
+
this.registerDynamicTag(id, options);
|
|
187
|
+
},
|
|
188
|
+
__hostRemoveDynamicTag: (id) => {
|
|
189
|
+
const activeMap = this.dynamicTagsStack[this.dynamicTagsStack.length - 1];
|
|
190
|
+
activeMap.delete(id);
|
|
191
|
+
},
|
|
192
|
+
__hostGetTagInfo: (id) => {
|
|
193
|
+
if (!this.mapperFile) return null;
|
|
194
|
+
const target = this.mapperFile.get(id);
|
|
195
|
+
if (!target) return null;
|
|
196
|
+
return JSON.stringify({
|
|
197
|
+
options: target.options || {}
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
__hostCallTagRender: async (id, payloadStr) => {
|
|
201
|
+
if (!this.mapperFile) return "";
|
|
202
|
+
const target = this.mapperFile.get(id);
|
|
203
|
+
if (!target) return "";
|
|
204
|
+
const payload = JSON.parse(payloadStr);
|
|
205
|
+
return await target.render.call(this.mapperFile, payload);
|
|
206
|
+
},
|
|
207
|
+
__allowRaw: this.security.allowRaw !== false
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await this.runtime.vm.evalCode(`
|
|
211
|
+
const __nativeFetch = globalThis.fetch;
|
|
212
|
+
class TagBuilder {
|
|
213
|
+
constructor(tagName) {
|
|
214
|
+
this.tagName = tagName;
|
|
215
|
+
this._children = "";
|
|
216
|
+
this._attr = [];
|
|
217
|
+
this._is_self_close = false;
|
|
218
|
+
}
|
|
219
|
+
attributes(obj) {
|
|
220
|
+
if (obj && typeof obj === "object") {
|
|
221
|
+
Object.entries(obj).forEach(([key, value]) => {
|
|
222
|
+
if (value === true) this._attr.push(key);
|
|
223
|
+
else if (value !== false && value !== null && value !== undefined) {
|
|
224
|
+
const esc = String(value)
|
|
225
|
+
.replace(/&/g, "&")
|
|
226
|
+
.replace(/</g, "<")
|
|
227
|
+
.replace(/>/g, ">")
|
|
228
|
+
.replace(/"/g, """)
|
|
229
|
+
.replace(/'/g, "'");
|
|
230
|
+
this._attr.push(\`\${key}="\${esc}"\`);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
body(nodes) {
|
|
237
|
+
if (nodes) {
|
|
238
|
+
this._children += (this._children ? " " : "") + nodes;
|
|
239
|
+
}
|
|
240
|
+
return this.builder();
|
|
241
|
+
}
|
|
242
|
+
selfClose() {
|
|
243
|
+
this._is_self_close = true;
|
|
244
|
+
return this.builder();
|
|
245
|
+
}
|
|
246
|
+
builder() {
|
|
247
|
+
const props = this._attr.length > 0 ? " " + this._attr.join(" ") : "";
|
|
248
|
+
if (this._is_self_close) {
|
|
249
|
+
return \`<\${this.tagName}\${props} />\`;
|
|
250
|
+
}
|
|
251
|
+
return \`<\${this.tagName}\${props}>\${this._children}</\${this.tagName}>\`;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const SomMark = {
|
|
256
|
+
version: __hostSomMarkVersion,
|
|
257
|
+
__dynamicTags: new Map(),
|
|
258
|
+
register: function(id, render, options = {}) {
|
|
259
|
+
if (typeof id !== "string") {
|
|
260
|
+
throw new Error("SomMark.register Error: Tag ID must be a string.");
|
|
261
|
+
}
|
|
262
|
+
if (typeof render !== "function") {
|
|
263
|
+
throw new Error("SomMark.register Error: Render function must be a function.");
|
|
264
|
+
}
|
|
265
|
+
this.__dynamicTags.set(id, { render, options });
|
|
266
|
+
__hostRegisterDynamicTag(id, options);
|
|
267
|
+
},
|
|
268
|
+
get: function(id) {
|
|
269
|
+
if (typeof id !== "string") {
|
|
270
|
+
throw new Error("SomMark.get Error: Tag ID must be a string.");
|
|
271
|
+
}
|
|
272
|
+
const local = this.__dynamicTags.get(id);
|
|
273
|
+
if (local) {
|
|
274
|
+
return {
|
|
275
|
+
options: local.options || {},
|
|
276
|
+
render: local.render
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const hostInfoStr = __hostGetTagInfo(id);
|
|
280
|
+
if (hostInfoStr) {
|
|
281
|
+
const hostInfo = JSON.parse(hostInfoStr);
|
|
282
|
+
return {
|
|
283
|
+
options: hostInfo.options || {},
|
|
284
|
+
render: async function(payload) {
|
|
285
|
+
return await __hostCallTagRender(id, JSON.stringify(payload));
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
},
|
|
291
|
+
removeOutput: function(id) {
|
|
292
|
+
if (typeof id !== "string") {
|
|
293
|
+
throw new Error("SomMark.removeOutput Error: Tag ID must be a string.");
|
|
294
|
+
}
|
|
295
|
+
this.__dynamicTags.delete(id);
|
|
296
|
+
__hostRemoveDynamicTag(id);
|
|
297
|
+
},
|
|
298
|
+
includesId: function(ids) {
|
|
299
|
+
if (!Array.isArray(ids)) {
|
|
300
|
+
throw new Error("SomMark.includesId Error: Expected an array of IDs.");
|
|
301
|
+
}
|
|
302
|
+
if (ids.some(id => this.__dynamicTags.has(id))) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
return ids.some(id => __hostGetTagInfo(id) !== null);
|
|
306
|
+
},
|
|
307
|
+
tag: function(tagName) {
|
|
308
|
+
if (typeof tagName !== "string") {
|
|
309
|
+
throw new Error("SomMark.tag Error: Tag name must be a string.");
|
|
310
|
+
}
|
|
311
|
+
return new TagBuilder(tagName);
|
|
312
|
+
},
|
|
313
|
+
get settings() {
|
|
314
|
+
const parsed = JSON.parse(__hostSomMarkSettings() || "{}");
|
|
315
|
+
Object.defineProperty(parsed, "__raw", {
|
|
316
|
+
value: JSON.stringify(parsed),
|
|
317
|
+
enumerable: false,
|
|
318
|
+
writable: false,
|
|
319
|
+
configurable: false
|
|
320
|
+
});
|
|
321
|
+
return Object.freeze(parsed);
|
|
322
|
+
},
|
|
323
|
+
fetch: async (input, init) => {
|
|
324
|
+
const plainRes = await __nativeFetch(input, init);
|
|
325
|
+
return {
|
|
326
|
+
status: plainRes.status,
|
|
327
|
+
ok: plainRes.ok,
|
|
328
|
+
statusText: plainRes.statusText,
|
|
329
|
+
url: plainRes.url,
|
|
330
|
+
type: plainRes.type,
|
|
331
|
+
redirected: plainRes.redirected,
|
|
332
|
+
headers: {
|
|
333
|
+
get: (name) => plainRes.headers[name.toLowerCase()] || null,
|
|
334
|
+
forEach: (cb) => {
|
|
335
|
+
Object.keys(plainRes.headers).forEach(key => cb(plainRes.headers[key], key));
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
text: async () => plainRes.bodyText,
|
|
339
|
+
json: async () => JSON.parse(plainRes.bodyText),
|
|
340
|
+
clone: function() { return { ...this }; }
|
|
341
|
+
};
|
|
342
|
+
},
|
|
343
|
+
compile: async (src, options) => {
|
|
344
|
+
if (src === null || src === undefined) {
|
|
345
|
+
throw new Error("SomMark.compile Error: Template source cannot be null or undefined.");
|
|
346
|
+
}
|
|
347
|
+
if (typeof src === "function") {
|
|
348
|
+
throw new Error("SomMark.compile Error: Cannot pass a function as the template source. Did you forget to invoke/call it?");
|
|
349
|
+
}
|
|
350
|
+
if (src instanceof Promise || (typeof src === "object" && typeof src.then === "function")) {
|
|
351
|
+
throw new Error("SomMark.compile Error: Cannot pass a Promise as the template source. Did you forget to use 'await'?");
|
|
352
|
+
}
|
|
353
|
+
if (typeof src !== "string") {
|
|
354
|
+
throw new Error("SomMark.compile Error: Template source must be a string.");
|
|
355
|
+
}
|
|
356
|
+
return await __hostCompile(src, options);
|
|
357
|
+
},
|
|
358
|
+
raw: (html) => {
|
|
359
|
+
if (typeof __allowRaw !== "undefined" && !__allowRaw) {
|
|
360
|
+
throw new Error("Security Error: SomMark.raw is disabled in this environment.");
|
|
361
|
+
}
|
|
362
|
+
if (html === null || html === undefined) {
|
|
363
|
+
return { __raw: "" };
|
|
364
|
+
}
|
|
365
|
+
if (typeof html === "function") {
|
|
366
|
+
throw new Error("SomMark.raw Error: Cannot pass a function directly to SomMark.raw. Did you forget to invoke/call it?");
|
|
367
|
+
}
|
|
368
|
+
if (html instanceof Promise || (typeof html === "object" && typeof html.then === "function")) {
|
|
369
|
+
throw new Error("SomMark.raw Error: Cannot pass a Promise directly to SomMark.raw. Did you forget to use 'await'?");
|
|
370
|
+
}
|
|
371
|
+
if (typeof html === "object" && !html.__raw) {
|
|
372
|
+
throw new Error("SomMark.raw Error: Cannot render an object directly.");
|
|
373
|
+
}
|
|
374
|
+
return { __raw: String(html.__raw !== undefined ? html.__raw : html) };
|
|
375
|
+
},
|
|
376
|
+
static: (expr) => {
|
|
377
|
+
if (typeof expr !== "string") {
|
|
378
|
+
throw new Error("SomMark.static Error: Argument must be a string.");
|
|
379
|
+
}
|
|
380
|
+
return globalThis.eval(expr);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Deep freeze the SomMark standard library to make it completely immutable
|
|
385
|
+
Object.freeze(SomMark);
|
|
386
|
+
|
|
387
|
+
// Establish the global SomMark constant (non-writable, non-configurable)
|
|
388
|
+
Object.defineProperty(globalThis, "SomMark", {
|
|
389
|
+
value: SomMark,
|
|
390
|
+
writable: false,
|
|
391
|
+
configurable: false
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Prevent direct/un-namespaced global fetch usage to enforce standard library architecture
|
|
395
|
+
delete globalThis.fetch;
|
|
396
|
+
delete globalThis.process;
|
|
397
|
+
`);
|
|
398
|
+
|
|
399
|
+
// Configure host-based module loader to support local imports perfectly
|
|
400
|
+
this.runtime.vm.context.runtime.setModuleLoader((moduleName) => {
|
|
401
|
+
try {
|
|
402
|
+
const isRaw = moduleName.endsWith("?raw");
|
|
403
|
+
const cleanModuleName = isRaw ? moduleName.slice(0, -4) : moduleName;
|
|
404
|
+
const resolvedPath = path.resolve(this.baseDir, cleanModuleName);
|
|
405
|
+
if (fs.existsSync(resolvedPath)) {
|
|
406
|
+
let source = fs.readFileSync(resolvedPath, "utf8");
|
|
407
|
+
|
|
408
|
+
if (isRaw) {
|
|
409
|
+
const escapedSource = source.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\${/g, "\\${");
|
|
410
|
+
return `export default \`${escapedSource}\`;`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Support JSON files
|
|
414
|
+
if (resolvedPath.endsWith(".json")) {
|
|
415
|
+
source = `export default ${source};`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Support Smark files
|
|
419
|
+
if (resolvedPath.endsWith(".smark")) {
|
|
420
|
+
const escapedSource = source.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\${/g, "\\${");
|
|
421
|
+
source = `
|
|
422
|
+
export default async (variables = {}) => {
|
|
423
|
+
return await SomMark.compile(\`${escapedSource}\`, { variables });
|
|
424
|
+
};
|
|
425
|
+
`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return source; // MUST BE A STRING
|
|
429
|
+
}
|
|
430
|
+
throw new Error(`Module not found: ${moduleName}`);
|
|
431
|
+
} catch (err) {
|
|
432
|
+
throw err;
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Pushes a new block scope level.
|
|
439
|
+
*/
|
|
440
|
+
pushScope() {
|
|
441
|
+
this.scopes.push({});
|
|
442
|
+
this.dynamicTagsStack.push(new Map());
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Pops the current block scope level, cleaning up VM globals and restoring parent scope variables.
|
|
447
|
+
*/
|
|
448
|
+
async popScope() {
|
|
449
|
+
if (this.scopes.length > 1) {
|
|
450
|
+
const popped = this.scopes.pop();
|
|
451
|
+
this.dynamicTagsStack.pop();
|
|
452
|
+
const keysToDelete = Object.keys(popped);
|
|
453
|
+
if (keysToDelete.length > 0 && this.runtime) {
|
|
454
|
+
try {
|
|
455
|
+
const deleteCode = keysToDelete.map(k => `delete globalThis['${k}'];`).join(" ");
|
|
456
|
+
await this.runtime.vm.evalCode(deleteCode, "cleanup.js");
|
|
457
|
+
} catch (e) {
|
|
458
|
+
// ignore
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Restore parent scopes
|
|
462
|
+
if (this.runtime) {
|
|
463
|
+
const merged = {};
|
|
464
|
+
for (const scope of this.scopes) {
|
|
465
|
+
Object.assign(merged, scope);
|
|
466
|
+
}
|
|
467
|
+
this.runtime.vm.expose(merged);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
hasDynamicTag(id) {
|
|
473
|
+
for (let i = this.dynamicTagsStack.length - 1; i >= 0; i--) {
|
|
474
|
+
if (this.dynamicTagsStack[i].has(id)) return true;
|
|
475
|
+
}
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
getDynamicTagOptions(id) {
|
|
480
|
+
for (let i = this.dynamicTagsStack.length - 1; i >= 0; i--) {
|
|
481
|
+
const entry = this.dynamicTagsStack[i].get(id);
|
|
482
|
+
if (entry) return entry.options;
|
|
483
|
+
}
|
|
484
|
+
return {};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
registerDynamicTag(id, options = {}) {
|
|
488
|
+
const activeMap = this.dynamicTagsStack[this.dynamicTagsStack.length - 1];
|
|
489
|
+
activeMap.set(id, { options });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async executeDynamicTag(id, payload) {
|
|
493
|
+
if (!this.runtime) throw new Error("EvaluatorState not initialized");
|
|
494
|
+
this.runtime.vm.expose({
|
|
495
|
+
__activeTagPayload: () => JSON.stringify(payload)
|
|
496
|
+
});
|
|
497
|
+
const code = `
|
|
498
|
+
(() => {
|
|
499
|
+
const payload = JSON.parse(__activeTagPayload());
|
|
500
|
+
const tag = SomMark.__dynamicTags.get(${JSON.stringify(id)});
|
|
501
|
+
if (!tag) throw new Error("Tag not found inside VM: " + ${JSON.stringify(id)});
|
|
502
|
+
const res = tag.render({
|
|
503
|
+
args: payload.args,
|
|
504
|
+
content: payload.content,
|
|
505
|
+
textContent: payload.textContent,
|
|
506
|
+
nodeType: payload.nodeType,
|
|
507
|
+
isSelfClosing: payload.isSelfClosing
|
|
508
|
+
});
|
|
509
|
+
return res;
|
|
510
|
+
})()
|
|
511
|
+
`;
|
|
512
|
+
let result = await this.runtime.vm.evalCode(code, "render_tag.js");
|
|
513
|
+
if (result instanceof Promise || (result && typeof result === "object" && typeof result.then === "function")) {
|
|
514
|
+
result = await result;
|
|
515
|
+
}
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Synchronizes changed VM global variables back to the scope stack.
|
|
521
|
+
*/
|
|
522
|
+
async _syncScopes() {
|
|
523
|
+
if (!this.runtime) return;
|
|
524
|
+
const allKeysSet = new Set();
|
|
525
|
+
for (const scope of this.scopes) {
|
|
526
|
+
for (const key of Object.keys(scope)) {
|
|
527
|
+
allKeysSet.add(key);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const allKeys = Array.from(allKeysSet);
|
|
531
|
+
if (allKeys.length > 0) {
|
|
532
|
+
try {
|
|
533
|
+
const getValuesCode = `export default { ${allKeys.map(k => `${JSON.stringify(k)}: globalThis['${k}']`).join(", ")} };`;
|
|
534
|
+
const valuesRes = await this.runtime.vm.evalCode(getValuesCode, "sync.js", { type: 'module' });
|
|
535
|
+
if (valuesRes && typeof valuesRes === 'object' && 'default' in valuesRes) {
|
|
536
|
+
const syncedValues = valuesRes.default;
|
|
537
|
+
for (const [key, val] of Object.entries(syncedValues)) {
|
|
538
|
+
for (let s = this.scopes.length - 1; s >= 0; s--) {
|
|
539
|
+
if (key in this.scopes[s]) {
|
|
540
|
+
this.scopes[s][key] = val;
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
} catch (err) {
|
|
547
|
+
// ignore
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Injects variables safely into the sandbox.
|
|
554
|
+
*/
|
|
555
|
+
inject(vars) {
|
|
556
|
+
if (!this.runtime) return;
|
|
557
|
+
const currentScope = this.scopes[this.scopes.length - 1];
|
|
558
|
+
Object.assign(currentScope, vars);
|
|
559
|
+
this.runtime.vm.expose(vars);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Executes code asynchronously and returns resolved result.
|
|
564
|
+
*/
|
|
565
|
+
async execute(code) {
|
|
566
|
+
if (!this.runtime) throw new Error("Evaluator not initialized");
|
|
567
|
+
|
|
568
|
+
const timeout = this.security?.timeout ?? 5000;
|
|
569
|
+
this.deadline = Date.now() + timeout; // Dynamic timeout safety safeguard
|
|
570
|
+
|
|
571
|
+
// Keep QuickJS event loop alive in the background during execution
|
|
572
|
+
const interval = setInterval(() => {
|
|
573
|
+
try {
|
|
574
|
+
this.runtime.vm.context.runtime.executePendingJobs();
|
|
575
|
+
} catch (err) {
|
|
576
|
+
// ignore
|
|
577
|
+
}
|
|
578
|
+
}, 1);
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
// Detect top-level declarations for Auto-Export
|
|
582
|
+
let autoExportedNames = [];
|
|
583
|
+
let hasExplicitExports = false;
|
|
584
|
+
try {
|
|
585
|
+
const ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module', allowReturnOutsideFunction: true });
|
|
586
|
+
for (const node of ast.body) {
|
|
587
|
+
if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration' || node.type === 'ExportAllDeclaration') {
|
|
588
|
+
hasExplicitExports = true;
|
|
589
|
+
}
|
|
590
|
+
if (node.type === 'VariableDeclaration') {
|
|
591
|
+
for (const decl of node.declarations) {
|
|
592
|
+
if (decl.id.type === 'Identifier') autoExportedNames.push(decl.id.name);
|
|
593
|
+
else if (decl.id.type === 'ObjectPattern') {
|
|
594
|
+
for (const prop of decl.id.properties) {
|
|
595
|
+
if (prop.value.type === 'Identifier') autoExportedNames.push(prop.value.name);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
} else if (node.type === 'FunctionDeclaration') {
|
|
600
|
+
if (node.id) autoExportedNames.push(node.id.name);
|
|
601
|
+
} else if (node.type === 'ImportDeclaration') {
|
|
602
|
+
for (const spec of node.specifiers) {
|
|
603
|
+
autoExportedNames.push(spec.local.name);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
} catch (e) {
|
|
608
|
+
// If it fails to parse as module, it might be a simple expression, ignore
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const hasImportExport = hasExplicitExports || /\bimport\b/.test(code);
|
|
612
|
+
const hasAwait = /\bawait\b/.test(code);
|
|
613
|
+
|
|
614
|
+
let finalCode = code;
|
|
615
|
+
|
|
616
|
+
// Rewrite the last expression statement to be export default so we automatically return its value
|
|
617
|
+
try {
|
|
618
|
+
const ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module', allowReturnOutsideFunction: true });
|
|
619
|
+
const lastNode = ast.body[ast.body.length - 1];
|
|
620
|
+
if (lastNode && lastNode.type === 'ExpressionStatement') {
|
|
621
|
+
const start = lastNode.start;
|
|
622
|
+
finalCode = code.slice(0, start) + "export default " + code.slice(start);
|
|
623
|
+
} else if (lastNode && lastNode.type === 'ReturnStatement') {
|
|
624
|
+
const start = lastNode.start;
|
|
625
|
+
if (lastNode.argument) {
|
|
626
|
+
const argumentCode = code.slice(lastNode.argument.start, lastNode.argument.end);
|
|
627
|
+
finalCode = code.slice(0, start) + `export default (${argumentCode});` + code.slice(lastNode.end);
|
|
628
|
+
} else {
|
|
629
|
+
finalCode = code.slice(0, start) + "export default undefined;" + code.slice(lastNode.end);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
} catch (err) {
|
|
633
|
+
// Ignore parsing errors and fallback to raw code
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (autoExportedNames.length > 0 && !hasExplicitExports) {
|
|
637
|
+
finalCode += `\nexport { ${autoExportedNames.join(', ')} };`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const isModule = hasImportExport || hasAwait || autoExportedNames.length > 0 || finalCode.includes("export default");
|
|
641
|
+
|
|
642
|
+
let result;
|
|
643
|
+
if (isModule) {
|
|
644
|
+
// Evaluate as module using Arena
|
|
645
|
+
const evalPromise = this.runtime.vm.evalCode(finalCode, "main.js", {
|
|
646
|
+
strict: true,
|
|
647
|
+
strip: true,
|
|
648
|
+
backtraceBarrier: true,
|
|
649
|
+
type: 'module'
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const res = await evalPromise;
|
|
653
|
+
|
|
654
|
+
// Move exports directly to global scope in the VM
|
|
655
|
+
if (res && typeof res === 'object') {
|
|
656
|
+
const currentScope = this.scopes[this.scopes.length - 1];
|
|
657
|
+
for (const [key, val] of Object.entries(res)) {
|
|
658
|
+
if (key !== 'default') {
|
|
659
|
+
currentScope[key] = val;
|
|
660
|
+
this.runtime.vm.expose({ [key]: val });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if ('default' in res) {
|
|
664
|
+
result = res.default;
|
|
665
|
+
} else {
|
|
666
|
+
result = undefined;
|
|
667
|
+
}
|
|
668
|
+
} else {
|
|
669
|
+
result = res;
|
|
670
|
+
}
|
|
671
|
+
} else {
|
|
672
|
+
result = await this.runtime.vm.evalCode(code, "main.js");
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (result instanceof Promise || (result && typeof result === "object" && typeof result.then === "function")) {
|
|
676
|
+
result = await result;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
await this._syncScopes();
|
|
680
|
+
return result;
|
|
681
|
+
} catch (error) {
|
|
682
|
+
// Try to extract line/col from stack trace
|
|
683
|
+
const stack = error.stack || "";
|
|
684
|
+
const match = stack.match(/main\.js:(\d+):(\d+)/) || stack.match(/:(\d+):(\d+)/);
|
|
685
|
+
|
|
686
|
+
const err = new Error(error.message || error);
|
|
687
|
+
if (match) {
|
|
688
|
+
err.line = parseInt(match[1]);
|
|
689
|
+
err.column = parseInt(match[2]);
|
|
690
|
+
}
|
|
691
|
+
throw err;
|
|
692
|
+
} finally {
|
|
693
|
+
this.deadline = 0;
|
|
694
|
+
clearInterval(interval);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Disposal.
|
|
700
|
+
*/
|
|
701
|
+
destroy() {
|
|
702
|
+
if (this.runtime) {
|
|
703
|
+
try {
|
|
704
|
+
// Execute any lingering jobs & trigger the QuickJS garbage collector
|
|
705
|
+
if (this.runtime.vm?.context?.runtime) {
|
|
706
|
+
this.runtime.vm.context.runtime.executePendingJobs();
|
|
707
|
+
this.runtime.vm.context.runtime.gc();
|
|
708
|
+
}
|
|
709
|
+
} catch (e) { }
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
this.runtime.dispose();
|
|
713
|
+
} catch (e) {
|
|
714
|
+
// Graceful logging for minor Emscripten reference delays
|
|
715
|
+
console.warn("<$yellow:Warning:$> Safe context disposal warning: " + e.message);
|
|
716
|
+
}
|
|
717
|
+
this.runtime = null;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Evaluator
|
|
724
|
+
*
|
|
725
|
+
* Acts as a router/proxy singleton that routes VM calls to a stack of active isolated runtimes.
|
|
726
|
+
* This guarantees concurrent and recursive safety across all compiler runs.
|
|
727
|
+
*/
|
|
728
|
+
class Evaluator {
|
|
729
|
+
constructor() {
|
|
730
|
+
this.instances = [];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Get the active logic engine state instance at the top of the stack.
|
|
735
|
+
*/
|
|
736
|
+
get active() {
|
|
737
|
+
if (this.instances.length === 0) {
|
|
738
|
+
throw new Error("No active EvaluatorState instance. Did you call init()?");
|
|
739
|
+
}
|
|
740
|
+
return this.instances[this.instances.length - 1];
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async init(baseDir = null, security = {}, settings = {}, mapperFile = null) {
|
|
744
|
+
const state = new EvaluatorState();
|
|
745
|
+
await state.init(baseDir, security, settings, mapperFile);
|
|
746
|
+
this.instances.push(state);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
destroy() {
|
|
750
|
+
if (this.instances.length > 0) {
|
|
751
|
+
const state = this.instances.pop();
|
|
752
|
+
state.destroy();
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
pushScope() {
|
|
757
|
+
this.active.pushScope();
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async popScope() {
|
|
761
|
+
await this.active.popScope();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
inject(vars) {
|
|
765
|
+
this.active.inject(vars);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async execute(code) {
|
|
769
|
+
return await this.active.execute(code);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
hasDynamicTag(id) {
|
|
773
|
+
return this.active.hasDynamicTag(id);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
getDynamicTagOptions(id) {
|
|
777
|
+
return this.active.getDynamicTagOptions(id);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async executeDynamicTag(id, payload) {
|
|
781
|
+
return await this.active.executeDynamicTag(id, payload);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export default new Evaluator();
|