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 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
- // Each transpile() call gets its own isolated EvaluatorState stack via async context.
9
- const evaluatorStorage = new AsyncLocalStorage();
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
- const resolvedPath = /^https?:\/\//.test(this.baseDir)
555
- ? new URL(cleanModuleName, this.baseDir.endsWith("/") ? this.baseDir : this.baseDir + "/").href
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, vars);
742
- this.expose(vars);
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, "main.js", { type: 'module' });
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, "main.js");
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(/main\.js:(\d+):(\d+)/) || stack.match(/:(\d+):(\d+)/);
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. 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.1.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
@@ -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
- 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 = "";
@@ -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
- `'i' is a reserved variable name for the loop index.{N}Use a different name for the 'as' prop, e.g. as: "item"{line}`,
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
- let output = "";
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
- output += await generateOutput(cleanedBody, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState, extraCtx);
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 output;
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.props?.["smark-raw"] === "true" || node.props?.["smark-raw"] === true)) {
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 { "smark-raw": _, ...cleanArgs } = node.props;
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
- // Check if the argument exists in named args or as a positional arg (if arg is a number)
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
  /**