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 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
- // Each transpile() call gets its own isolated EvaluatorState stack via async context.
9
- const evaluatorStorage = new AsyncLocalStorage();
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 clean = { ...SomMark.settings };
319
- delete clean.instance;
320
- delete clean.fs;
321
- return JSON.stringify(clean);
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
- // Configure module loader using virtual FS implementation
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
- const resolvedPath = /^https?:\/\//.test(this.baseDir)
555
- ? new URL(cleanModuleName, this.baseDir.endsWith("/") ? this.baseDir : this.baseDir + "/").href
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, vars);
742
- this.expose(vars);
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
- // Ignore parsing errors and fallback to raw code
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, "main.js", { type: 'module' });
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, "main.js");
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(/main\.js:(\d+):(\d+)/) || stack.match(/:(\d+):(\d+)/);
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. Check the Target Directory (Highest Priority)
55
+ // 2. Walk up from startDir looking for the config file
56
56
  if (!configPath) {
57
- const targetConfig = path.join(startDir, CONFIG_FILE_NAME);
58
- try {
59
- await fs.access(targetConfig);
60
- configPath = targetConfig;
61
- } catch {
62
- // No config found in target dir
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
  }
@@ -18,7 +18,7 @@ export function registerHostSettings(settings) {
18
18
  hostSettings = settings || {};
19
19
  }
20
20
 
21
- const version = "5.0.5";
21
+ const version = "5.2.0";
22
22
 
23
23
  const SomMark = {
24
24
  version,
@@ -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 internal whitespace-only nodes that are adjacent to non-rendering nodes
199
- // (Comments, Imports, etc. shouldn't leave "ghost" newlines)
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
- const prev = nodes[idx - 1];
205
- const next = nodes[idx + 1];
206
- const isAdjacentToNonRendering =
207
- (prev && nonRenderingTypes.includes(prev.type)) ||
208
- (next && nonRenderingTypes.includes(next.type));
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 !isAdjacentToNonRendering;
220
+ return !(prevIsNonRendering || nextIsNonRendering);
211
221
  });
212
222
 
213
- // 2. Final pass: trim leading/trailing newlines from the remaining boundary text nodes
214
- if (res.length > 0 && res[0].type === TEXT) {
215
- res[0].text = res[0].text.replace(/^[\r\n]+/, "");
216
- }
217
- if (res.length > 0 && res[res.length - 1].type === TEXT) {
218
- res[res.length - 1].text = res[res.length - 1].text.replace(/[\r\n]+\s*$/, "");
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
- // 3. Remove any nodes that became purely empty after trimming
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
- resolvedPath = path.resolve(context.instance.cwd || "/", filePath.replace(prefix, replacement));
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. Resolve relative to current base (FS)
291
- const absolutePath = resolveModulePath(resolvedPath, currentBaseDir);
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
- blockNode.props[String(argIndex++)] = v;
637
- if (k) {
638
- blockNode.props[k] = v;
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 = "";