sommark 4.0.3 → 4.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.
Files changed (43) hide show
  1. package/README.md +304 -73
  2. package/cli/cli.mjs +1 -1
  3. package/cli/commands/build.js +3 -1
  4. package/cli/commands/help.js +2 -0
  5. package/cli/commands/init.js +25 -6
  6. package/cli/constants.js +2 -1
  7. package/cli/helpers/transpile.js +5 -2
  8. package/constants/html_props.js +1 -0
  9. package/core/evaluator.js +1061 -0
  10. package/core/formats.js +15 -7
  11. package/core/helpers/config-loader.js +16 -8
  12. package/core/helpers/lib.js +72 -0
  13. package/core/helpers/preprocessor.js +202 -0
  14. package/core/helpers/runtimeOutput.js +28 -0
  15. package/core/helpers/url.js +12 -0
  16. package/core/labels.js +9 -2
  17. package/core/lexer.js +228 -61
  18. package/core/modules.js +338 -60
  19. package/core/parser.js +275 -55
  20. package/core/tokenTypes.js +11 -0
  21. package/core/transpiler.js +352 -66
  22. package/core/validator.js +70 -7
  23. package/formatter/tag.js +31 -7
  24. package/grammar.ebnf +21 -10
  25. package/helpers/fetch-fs.js +37 -0
  26. package/helpers/safeDataParser.js +3 -3
  27. package/helpers/spinner.js +97 -0
  28. package/helpers/utils.js +46 -0
  29. package/helpers/virtual-fs.js +29 -0
  30. package/index.browser.js +87 -0
  31. package/index.js +23 -332
  32. package/index.shared.js +443 -0
  33. package/mappers/languages/html.js +50 -9
  34. package/mappers/languages/json.js +81 -38
  35. package/mappers/languages/jsonc.js +82 -0
  36. package/mappers/languages/markdown.js +88 -48
  37. package/mappers/languages/mdx.js +50 -15
  38. package/mappers/languages/text.js +67 -0
  39. package/mappers/languages/xml.js +6 -6
  40. package/mappers/mapper.js +36 -4
  41. package/mappers/shared/index.js +12 -13
  42. package/package.json +11 -2
  43. package/core/formatter.js +0 -215
@@ -19,12 +19,15 @@
19
19
  * @property {string} COMMA - ',' char.
20
20
  * @property {string} SEMICOLON - ';' char (At-Block separator).
21
21
  * @property {string} COMMENT - '#' comments.
22
+ * @property {string} COMMENT_BLOCK - '###' comments.
22
23
  * @property {string} ESCAPE - '\' char. Used for literalizing structural chars like '\"' or '\['.
23
24
  * @property {string} QUOTE - '"' delimiter.
25
+ * @property {string} EXCLAMATION_MARK - '!' char.
24
26
  * @property {string} IMPORT - 'import' keyword.
25
27
  * @property {string} USE_MODULE - '$use-module' keyword.
26
28
  * @property {string} PREFIX_JS - 'js{}' prefix layer.
27
29
  * @property {string} PREFIX_P - 'p{}' placeholder layer.
30
+ * @property {string} PREFIX_V - 'v{}' local variable layer.
28
31
  * @property {string} EOF - End of File indicator.
29
32
  */
30
33
  const TOKEN_TYPES = {
@@ -39,6 +42,7 @@ const TOKEN_TYPES = {
39
42
  QUOTE: "QUOTE",
40
43
  PREFIX_JS: "PREFIX_JS",
41
44
  PREFIX_P: "PREFIX_P",
45
+ PREFIX_V: "PREFIX_V",
42
46
  TEXT: "TEXT",
43
47
  THIN_ARROW: "THIN_ARROW",
44
48
  OPEN_PAREN: "OPEN_PAREN",
@@ -49,9 +53,16 @@ const TOKEN_TYPES = {
49
53
  COMMA: "COMMA",
50
54
  SEMICOLON: "SEMICOLON",
51
55
  COMMENT: "COMMENT",
56
+ COMMENT_BLOCK: "COMMENT_BLOCK",
52
57
  ESCAPE: "ESCAPE",
58
+ EXCLAMATION_MARK: "EXCLAMATION_MARK",
59
+ SLOT_KEYWORD: "SLOT_KEYWORD",
53
60
  KEY: "KEY",
54
61
  WHITESPACE: "WHITESPACE",
62
+ STATIC_KEYWORD: "STATIC_KEYWORD",
63
+ RUNTIME_KEYWORD: "RUNTIME_KEYWORD",
64
+ LOGIC: "LOGIC",
65
+ FOR_EACH: "FOR_EACH",
55
66
  EOF: "EOF"
56
67
  };
57
68
 
@@ -1,16 +1,18 @@
1
- import { BLOCK, TEXT, INLINE, ATBLOCK, COMMENT } from "./labels.js";
1
+ import { BLOCK, TEXT, INLINE, ATBLOCK, COMMENT, COMMENT_BLOCK, STATIC_LOGIC, RUNTIME_LOGIC, FOR_EACH } from "./labels.js";
2
2
  import { transpilerError } from "./errors.js";
3
- import { textFormat, htmlFormat, markdownFormat, mdxFormat, xmlFormat } from "./formats.js";
3
+ import evaluator from "./evaluator.js";
4
4
  import { matchedValue } from "../helpers/utils.js";
5
5
  import { dedentBy } from "../helpers/dedent.js";
6
+ import { preprocessRuntimeLogic } from "./helpers/preprocessor.js";
7
+ import { wrapRuntimeLogic } from "./helpers/runtimeOutput.js";
6
8
 
7
- /**
8
- * SomMark Transpiler
9
- * This engine converts the AST into its final text format (like HTML or Markdown)
10
- * using rules provided by a mapper.
11
- */
9
+ const randomBytesHex = (size) => {
10
+ const arr = new Uint8Array(size);
11
+ globalThis.crypto.getRandomValues(arr);
12
+ return Array.from(arr).map(b => b.toString(16).padStart(2, "0")).join("");
13
+ };
12
14
 
13
- const BODY_PLACEHOLDER = `SOMMARKBODYPLACEHOLDER${Math.random().toString(36).slice(2)}SOMMARK`;
15
+ const BODY_PLACEHOLDER = `SOMMARKBODYPLACEHOLDER${randomBytesHex(8)}SOMMARK`;
14
16
 
15
17
  /**
16
18
  * Extracts all plain text from a node and its children.
@@ -27,7 +29,7 @@ function getNodeText(node) {
27
29
  if (child.type === TEXT) text += child.text || "";
28
30
  else if (child.type === INLINE) text += child.value || "";
29
31
  else if (child.type === ATBLOCK) text += child.content || "";
30
- else if (child.type === BLOCK) text += getNodeText(child);
32
+ else if (child.type === BLOCK || child.type === FOR_EACH) text += getNodeText(child);
31
33
  }
32
34
  }
33
35
  return text;
@@ -43,7 +45,7 @@ function getNodeText(node) {
43
45
  * @param {Object} mapper_file - The rules for how to convert each node.
44
46
  * @returns {Promise<string>} - The final text for this node.
45
47
  */
46
- async function generateOutput(ast, i, format, mapper_file) {
48
+ async function generateOutput(ast, i, format, mapper_file, security = {}, parentId = null, generateRuntimeOutput = false, hideRuntimeOutput = false, instance = null) {
47
49
  const node = Array.isArray(ast) ? ast[i] : ast;
48
50
  if (!node) return "";
49
51
 
@@ -56,28 +58,157 @@ async function generateOutput(ast, i, format, mapper_file) {
56
58
  mapper_file.options.filename = node.args?.filename || oldFilename;
57
59
  let bodyOutput = "";
58
60
  if (node.body) {
61
+ evaluator.pushScope();
59
62
  for (let j = 0; j < node.body.length; j++) {
60
- bodyOutput += await generateOutput(node.body, j, format, mapper_file);
63
+ bodyOutput += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
61
64
  }
65
+ await evaluator.popScope();
62
66
  }
63
67
  mapper_file.options.filename = oldFilename;
64
68
  return bodyOutput;
65
69
  }
66
70
 
67
71
  if (node.type === TEXT) {
72
+ if (generateRuntimeOutput) return "";
68
73
  const text = String(node.text || "");
69
74
  return mapper_file ? mapper_file.text(text) : text;
70
75
  }
71
76
 
72
77
  if (node.type === COMMENT) {
73
- if (mapper_file?.options?.removeComments) return "";
74
- const cleanComment = String(node.text || "").replace(/^#/, "").trim();
75
- return " ".repeat(node.depth) + `${mapper_file?.comment(cleanComment) || ""}`;
78
+ if (generateRuntimeOutput || mapper_file?.options?.removeComments) return "";
79
+ return " ".repeat(node.depth) + `${mapper_file?.comment(node.text) || ""}`;
80
+ }
81
+
82
+ if (node.type === COMMENT_BLOCK) {
83
+ if (generateRuntimeOutput || mapper_file?.options?.removeComments) return "";
84
+ return " ".repeat(node.depth) + `${mapper_file?.commentBlock(node.text) || ""}`;
85
+ }
86
+
87
+ if (node.type === RUNTIME_LOGIC) {
88
+ const preprocessed = await preprocessRuntimeLogic(node.code, mapper_file?.options?.filename, security, instance);
89
+ if (hideRuntimeOutput) {
90
+ return "";
91
+ }
92
+ if (generateRuntimeOutput) {
93
+ return wrapRuntimeLogic(preprocessed, format, parentId, node.depth === 1);
94
+ }
95
+ return mapper_file ? mapper_file.runtimeLogic(preprocessed, node.depth === 1, parentId) : "";
96
+ }
97
+
98
+ if (node.type === STATIC_LOGIC) {
99
+ try {
100
+ const result = await evaluator.execute(node.code);
101
+ if (generateRuntimeOutput) return "";
102
+ if (result && typeof result === "object" && result.__raw !== undefined) {
103
+ if (security?.allowRaw === false) {
104
+ return mapper_file ? mapper_file.text(String(result.__raw)) : String(result.__raw);
105
+ }
106
+ const rawVal = String(result.__raw);
107
+ return (security?.sanitize && typeof security.sanitize === "function") ? security.sanitize(rawVal) : rawVal;
108
+ }
109
+ // Hide objects (like module exports) from the final output
110
+ const out = (result !== undefined && typeof result !== "object") ? String(result) : "";
111
+ return mapper_file ? mapper_file.text(out) : out;
112
+ } catch (err) {
113
+ transpilerError([
114
+ `<$red:Logic Error:$> ${err.message}{line}`,
115
+ `<$yellow:Code:$> <$blue:${node.code}$>{line}`
116
+ ]);
117
+ }
118
+ }
119
+
120
+ if (node.type === FOR_EACH) {
121
+ const transpiledArgs = await transpileArgs(node.args);
122
+ const items = mapper_file ? mapper_file.safeArg({ args: transpiledArgs, index: 0, key: "items", fallBack: [] }) : [];
123
+
124
+ if (!Array.isArray(items)) {
125
+ const line = node.range?.start?.line + 1 || 1;
126
+ transpilerError([
127
+ `<$red:Type Error in [for-each]:$>{line}`,
128
+ `Expected an <$green:Array$> for 'items', but received <$yellow:${typeof items}$>:<$cyan: ${JSON.stringify(items)}$>{line}`,
129
+ `at line <$yellow:${line}$>{line}`
130
+ ]);
131
+ return "";
132
+ }
133
+
134
+ const asVar = transpiledArgs.as || "item";
135
+ const indexVar = `${asVar}_index`;
136
+
137
+ // Trim structural whitespace/newlines at start and end of loop body for formatting clean output
138
+ let cleanedBody = [];
139
+ if (node.body) {
140
+ cleanedBody = [...node.body];
141
+
142
+ // Trim ALL leading pure-whitespace Text nodes
143
+ while (cleanedBody.length > 0 && cleanedBody[0].type === TEXT && /^\s*$/.test(cleanedBody[0].text)) {
144
+ cleanedBody.shift();
145
+ }
146
+ // If the now-first node is a Text node, trim its leading whitespace/newlines
147
+ if (cleanedBody.length > 0 && cleanedBody[0].type === TEXT) {
148
+ cleanedBody[0] = { ...cleanedBody[0], text: cleanedBody[0].text.replace(/^\s+/, "") };
149
+ }
150
+
151
+ // Trim ALL trailing pure-whitespace Text nodes
152
+ while (cleanedBody.length > 0 && cleanedBody[cleanedBody.length - 1].type === TEXT && /^\s*$/.test(cleanedBody[cleanedBody.length - 1].text)) {
153
+ cleanedBody.pop();
154
+ }
155
+ // If the now-last node is a Text node, trim its trailing whitespace/newlines
156
+ if (cleanedBody.length > 0 && cleanedBody[cleanedBody.length - 1].type === TEXT) {
157
+ cleanedBody[cleanedBody.length - 1] = { ...cleanedBody[cleanedBody.length - 1], text: cleanedBody[cleanedBody.length - 1].text.replace(/\s+$/, "") };
158
+ }
159
+ }
160
+
161
+ let output = "";
162
+ let idx = 0;
163
+ for (const item of items) {
164
+ evaluator.pushScope();
165
+ evaluator.inject({
166
+ [asVar]: item,
167
+ [indexVar]: idx++
168
+ });
169
+
170
+ for (let j = 0; j < cleanedBody.length; j++) {
171
+ output += await generateOutput(cleanedBody, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
172
+ }
173
+
174
+ await evaluator.popScope();
175
+ }
176
+ return output;
76
177
  }
77
178
 
78
- let target = mapper_file ? matchedValue(mapper_file.outputs, node.id) : null;
79
- if (!target && mapper_file) {
80
- target = mapper_file.getUnknownTag(node);
179
+ let secretId = null;
180
+ if (node.type === BLOCK) {
181
+ if (node.args) {
182
+ for (const key of Object.keys(node.args)) {
183
+ if (key.toLowerCase().startsWith("data-sommark")) {
184
+ transpilerError([
185
+ `<$red:Reserved Attribute Error:$> The attribute name '<$yellow:${key}$>' is reserved for SomMark's internal runtime compiler logic.{line}`,
186
+ `Please use a different attribute name.`
187
+ ]);
188
+ }
189
+ }
190
+ }
191
+
192
+ const hasRuntime = node.body?.some(child => child.type === RUNTIME_LOGIC);
193
+ if (hasRuntime) {
194
+ secretId = `sommark-${node.id.toLowerCase()}-${randomBytesHex(4)}`;
195
+ }
196
+ }
197
+
198
+ let target = null;
199
+ if (evaluator.active && evaluator.active.hasDynamicTag(node.id)) {
200
+ target = {
201
+ id: node.id,
202
+ options: evaluator.active.getDynamicTagOptions(node.id) || {},
203
+ render: async function (payload) {
204
+ return await evaluator.active.executeDynamicTag(node.id, payload);
205
+ }
206
+ };
207
+ } else {
208
+ target = mapper_file ? matchedValue(mapper_file.outputs, node.id) : null;
209
+ if (!target && mapper_file) {
210
+ target = mapper_file.getUnknownTag(node);
211
+ }
81
212
  }
82
213
 
83
214
  if (target) {
@@ -94,6 +225,11 @@ async function generateOutput(ast, i, format, mapper_file) {
94
225
  content = mapper_file ? mapper_file.inlineText(content, target.options) : content;
95
226
  }
96
227
 
228
+ if (node.type === ATBLOCK) {
229
+ content = String(content || "");
230
+ content = mapper_file ? mapper_file.atBlockBody(content, target.options) : content;
231
+ }
232
+
97
233
  // 1. Determine if this is a parent block that needs newline wrapping (Trim-and-Wrap)
98
234
  // Priority: Target options > Mapper global options
99
235
  const effectiveTrimAndWrap = (target.options?.trimAndWrapBlocks !== undefined)
@@ -109,23 +245,59 @@ async function generateOutput(ast, i, format, mapper_file) {
109
245
 
110
246
  if (shouldResolveImmediate && node.body) {
111
247
  let resolvedBody = "";
248
+ evaluator.pushScope();
112
249
  for (let j = 0; j < node.body.length; j++) {
113
- resolvedBody += await generateOutput(node.body, j, format, mapper_file);
250
+ resolvedBody += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
251
+ }
252
+ await evaluator.popScope();
253
+ content = dedentBy(resolvedBody, node.range?.start?.character || 0);
254
+ }
255
+
256
+ if (generateRuntimeOutput) {
257
+ let childrenOutput = "";
258
+ if (node.body) {
259
+ for (let j = 0; j < node.body.length; j++) {
260
+ childrenOutput += await generateOutput(node.body, j, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
261
+ }
114
262
  }
115
- content = resolvedBody;
263
+ return childrenOutput;
116
264
  }
117
265
 
118
- result += await target.render.call(mapper_file, { nodeType: node.type, args: node.args, content, textContent, ast: node });
119
- if (isParentBlock) result = "\n" + result + "\n";
266
+ const isManualMode = target.options?.handleAst === true;
267
+
268
+ const transpiledArgs = await transpileArgs(node.args);
269
+ if (secretId) {
270
+ transpiledArgs["data-sommark-id"] = secretId;
271
+ }
272
+ result += await target.render.call(mapper_file, {
273
+ nodeType: node.type,
274
+ args: transpiledArgs,
275
+ content,
276
+ textContent,
277
+ ast: isManualMode ? node : new Proxy({}, {
278
+ get(target, prop) {
279
+ if (prop === "then" || prop === "toJSON" || typeof prop === "symbol" || prop === "constructor" || prop === "inspect" || prop === "valueOf" || prop === "toString") {
280
+ return undefined;
281
+ }
282
+ transpilerError([
283
+ `<$red:Access Error:$> Attempted to access '<$yellow:ast.${String(prop)}$>', but '<$yellow:ast$>' is undefined because '<$cyan:handleAst$>' is false or not specified in this tag's registration options.{N}{N}`,
284
+ `Please set '<$green:handleAst: true$>' in the options object of your tag registration to get the actual AST node.`
285
+ ]);
286
+ }
287
+ }),
288
+ isSelfClosing: node.type === BLOCK ? (node.isSelfClosing || false) : undefined
289
+ });
290
+ // if (isParentBlock) result = "\n" + result;
120
291
 
121
292
  if (shouldResolveImmediate) {
122
293
  return result;
123
294
  }
124
295
 
125
- const isManualMode = target.options?.handleAst === true;
126
-
127
296
  if (!isManualMode && node.body) {
128
297
  let prev_body_node = null;
298
+ let prev_was_silent = false;
299
+ const parentEscape = (security?.allowRaw === false) ? true : (target.options?.escape !== false);
300
+ evaluator.pushScope();
129
301
  for (let j = 0; j < node.body.length; j++) {
130
302
  const body_node = node.body[j];
131
303
  let bodyOutput = "";
@@ -133,9 +305,13 @@ async function generateOutput(ast, i, format, mapper_file) {
133
305
  switch (body_node.type) {
134
306
  case TEXT:
135
307
  const text = String(body_node.text || "");
136
- // Dedent text relative to the parent block's indentation
137
- const localDedentedText = dedentBy(text, node.range?.start?.character || 0);
138
- bodyOutput = mapper_file ? mapper_file.text(localDedentedText, target?.options) : localDedentedText;
308
+ // Only dedent multi-line text inline spaces (no newlines) are separators, not indentation
309
+ const localDedentedText = text.includes("\n") ? dedentBy(text, node.range?.start?.character || 0) : text;
310
+ let bodyTextVal = mapper_file ? mapper_file.text(localDedentedText, { ...target?.options, escape: parentEscape }) : localDedentedText;
311
+ if (parentEscape === false && security?.sanitize && typeof security.sanitize === "function") {
312
+ bodyTextVal = security.sanitize(bodyTextVal);
313
+ }
314
+ bodyOutput = bodyTextVal;
139
315
  break;
140
316
 
141
317
  case INLINE:
@@ -175,54 +351,95 @@ async function generateOutput(ast, i, format, mapper_file) {
175
351
  }
176
352
 
177
353
  // Removed multiline injection since atBlockBody handles formatting
354
+ const transpiledAtArgs = await transpileArgs(body_node.args);
178
355
  bodyOutput = atTarget
179
- ? await atTarget.render.call(mapper_file, { nodeType: body_node.type, args: body_node.args, content: atContent, ast: body_node })
356
+ ? await atTarget.render.call(mapper_file, { nodeType: body_node.type, args: transpiledAtArgs, content: atContent, ast: body_node })
180
357
  : atContent;
181
358
  break;
182
359
 
183
360
  case COMMENT:
184
361
  if (mapper_file?.options?.removeComments) break;
185
- const cleanComment = String(body_node.text || "").replace(/^#/, "").trim();
186
- bodyOutput = " ".repeat(body_node.depth) + `${mapper_file.comment(cleanComment)}`;
362
+ bodyOutput = " ".repeat(body_node.depth) + `${mapper_file.comment(body_node.text)}`;
363
+ break;
364
+
365
+ case COMMENT_BLOCK:
366
+ if (mapper_file?.options?.removeComments) break;
367
+ bodyOutput = " ".repeat(body_node.depth) + `${mapper_file.commentBlock(body_node.text)}`;
187
368
  break;
188
369
 
370
+ case FOR_EACH:
189
371
  case BLOCK:
190
- bodyOutput = await generateOutput(body_node, 0, format, mapper_file);
372
+ bodyOutput = await generateOutput(body_node, 0, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
191
373
  break;
374
+
375
+ case RUNTIME_LOGIC:
376
+ const preprocessedBody = await preprocessRuntimeLogic(body_node.code, mapper_file?.options?.filename, security, instance);
377
+ if (hideRuntimeOutput) {
378
+ bodyOutput = "";
379
+ } else {
380
+ bodyOutput = mapper_file ? mapper_file.runtimeLogic(preprocessedBody, body_node.depth === 1, secretId || parentId) : "";
381
+ }
382
+ break;
383
+
384
+ case STATIC_LOGIC:
385
+ try {
386
+ const result = await evaluator.execute(body_node.code);
387
+ if (result && typeof result === "object" && result.__raw !== undefined) {
388
+ if (security?.allowRaw === false) {
389
+ bodyOutput = mapper_file ? mapper_file.text(String(result.__raw)) : String(result.__raw);
390
+ } else {
391
+ const rawVal = String(result.__raw);
392
+ bodyOutput = (security?.sanitize && typeof security.sanitize === "function") ? security.sanitize(rawVal) : rawVal;
393
+ }
394
+ } else {
395
+ const out = (result !== undefined && typeof result !== "object") ? String(result) : "";
396
+ bodyOutput = mapper_file ? mapper_file.text(out, { ...target?.options, escape: parentEscape }) : out;
397
+ }
398
+ } catch (err) {
399
+ transpilerError([
400
+ `<$red:Logic Error:$> ${err.message}{line}`,
401
+ `<$yellow:Code:$> <$blue:${body_node.code}$>{line}`
402
+ ]);
403
+ }
404
+ break;
405
+ }
406
+
407
+ if (prev_was_silent && body_node.type === TEXT) {
408
+ bodyOutput = bodyOutput.replace(/^\n/, "");
192
409
  }
193
410
 
194
411
  if (bodyOutput) {
195
412
  context += bodyOutput;
413
+ prev_was_silent = false;
414
+ } else {
415
+ prev_was_silent = true;
196
416
  }
197
417
  }
418
+ await evaluator.popScope();
198
419
  }
199
420
 
200
421
  const finalContext = effectiveTrimAndWrap ? context.replace(/^\s*[\r\n]+|[\r\n]+\s*$/g, "") : context;
201
422
 
202
423
  if (result.includes(BODY_PLACEHOLDER)) {
203
- result = result.replaceAll(BODY_PLACEHOLDER, finalContext);
204
- } else if (finalContext.trim()) {
205
- result += finalContext;
206
- }
207
- }
208
- else if (format === textFormat) {
209
- if (node.type === ATBLOCK) {
210
- result = node.content || "";
211
- } else if (node.type === INLINE) {
212
- result = node.value || "";
213
- } else if (node.body) {
214
- for (const body_node of node.body) {
215
- switch (body_node.type) {
216
- case TEXT: context += body_node.text || ""; break;
217
- case INLINE: context += (body_node.value || "") + " "; break;
218
- case ATBLOCK: context += (body_node.content || "").trimEnd() + "\n"; break;
219
- case BLOCK:
220
- const textBlockOutput = await generateOutput(body_node, 0, format, mapper_file);
221
- context = context.trim() ? context.trimEnd() + "\n" + textBlockOutput : context + textBlockOutput;
222
- break;
223
- }
424
+ if (finalContext === "") {
425
+ result = result
426
+ .replaceAll(`\n${BODY_PLACEHOLDER}\n`, "")
427
+ .replaceAll(`\r\n${BODY_PLACEHOLDER}\r\n`, "")
428
+ .replaceAll(BODY_PLACEHOLDER, "");
429
+ } else {
430
+ result = result.replaceAll(BODY_PLACEHOLDER, finalContext);
431
+ }
432
+ } else {
433
+ if (result.toLowerCase().includes(BODY_PLACEHOLDER.toLowerCase())) {
434
+ transpilerError([
435
+ `{line}<$red:Placeholder Corruption Error:$> Attempted to modify the '<$yellow:content$>' placeholder under '<$cyan:resolve: false$>' mode in tag '<$blue:${node.id}$>'.{line}`,
436
+ `This corrupts SomMark's internal compilation tokens and is not allowed.{line}`,
437
+ `If you need to read or alter the literal inner text, please use '<$green:textContent$>' instead.{line}`
438
+ ]);
439
+ }
440
+ if (finalContext.trim()) {
441
+ result += finalContext;
224
442
  }
225
- result += context;
226
443
  }
227
444
  } else {
228
445
  transpilerError([
@@ -247,6 +464,7 @@ export async function transpiler(optionsOrAst, format, mapperFile) {
247
464
  let body = null;
248
465
  let targetFormat = format;
249
466
  let targetMapper = mapperFile;
467
+ const security = (optionsOrAst && optionsOrAst.security) ? optionsOrAst.security : {};
250
468
 
251
469
  if (typeof optionsOrAst === "object" && !Array.isArray(optionsOrAst) && (optionsOrAst.ast || Array.isArray(optionsOrAst))) {
252
470
  if (optionsOrAst.ast) {
@@ -263,28 +481,96 @@ export async function transpiler(optionsOrAst, format, mapperFile) {
263
481
 
264
482
  if (!body || !Array.isArray(body)) return "";
265
483
 
484
+ const settings = optionsOrAst?.settings || { format: targetFormat || "html" };
485
+ const instance = optionsOrAst?.instance;
486
+ if (instance) {
487
+ settings.instance = instance;
488
+ settings.fs = instance.fs;
489
+ }
490
+
491
+ // Initialize Logic Sandbox
492
+ await evaluator.init(null, security, settings, targetMapper);
493
+ // Inject global data
494
+ const placeholders = optionsOrAst?.placeholders || settings?.placeholders || {};
495
+ const variables = optionsOrAst?.variables || settings?.variables || {};
496
+ evaluator.inject(placeholders);
497
+ evaluator.inject(variables);
498
+
266
499
  let output = "";
267
500
  let prev_body_node = null;
268
- for (let i = 0; i < body.length; i++) {
269
- const node = body[i];
270
- const blockOutput = await generateOutput(body, i, targetFormat, targetMapper);
271
-
272
- if (blockOutput) {
273
- output += blockOutput;
274
- if (node.type !== TEXT || node.text.trim().length > 0) {
275
- prev_body_node = node;
501
+ let prev_was_silent = false;
502
+ const generateRuntimeOutput = optionsOrAst?.generateRuntimeOutput || false;
503
+ const hideRuntimeOutput = optionsOrAst?.hideRuntimeOutput || false;
504
+ try {
505
+ for (let i = 0; i < body.length; i++) {
506
+ const node = body[i];
507
+ const blockOutput = await generateOutput(body, i, targetFormat, targetMapper, security, null, generateRuntimeOutput, hideRuntimeOutput, instance);
508
+
509
+ let finalBlockOutput = blockOutput;
510
+ if (prev_was_silent && node.type === TEXT) {
511
+ finalBlockOutput = finalBlockOutput.replace(/^\n/, "");
276
512
  }
277
- } else if (node.type === COMMENT && targetMapper?.options?.removeComments) {
278
- // If a comment is removed, check the next node.
279
- // If it's just a blank line, skip it so we don't have extra gaps in the output.
280
- const nextNode = body[i + 1];
281
- if (nextNode && nextNode.type === TEXT && (nextNode.text === "\n" || nextNode.text === "\r\n")) {
282
- i++; // Skip the next newline node
513
+
514
+ if (finalBlockOutput) {
515
+ output += finalBlockOutput;
516
+ prev_was_silent = false;
517
+ if (node.type !== TEXT || node.text.trim().length > 0) {
518
+ prev_body_node = node;
519
+ }
520
+ } else {
521
+ prev_was_silent = true;
522
+ if ((node.type === COMMENT || node.type === COMMENT_BLOCK) && targetMapper?.options?.removeComments) {
523
+ // If a comment is removed, check the next node.
524
+ // If it's just a blank line, skip it so we don't have extra gaps in the output.
525
+ const nextNode = body[i + 1];
526
+ if (nextNode && nextNode.type === TEXT && (nextNode.text === "\n" || nextNode.text === "\r\n")) {
527
+ i++; // Skip the next newline node
528
+ }
529
+ }
283
530
  }
284
531
  }
532
+ } finally {
533
+ evaluator.destroy();
285
534
  }
286
535
 
287
536
  return output.trim();
288
537
  }
289
538
 
539
+ /**
540
+ * Transpiles block arguments, resolving logic or variables.
541
+ */
542
+ async function transpileArgs(args) {
543
+ const result = {};
544
+ if (!args) return result;
545
+
546
+ for (const [key, value] of Object.entries(args)) {
547
+ if (key.toLowerCase().startsWith("data-sommark") && key.toLowerCase() !== "data-sommark-id") {
548
+ transpilerError([
549
+ `<$red:Reserved Attribute Error:$> The attribute name '<$yellow:${key}$>' is reserved for SomMark's internal runtime compiler logic.{line}`,
550
+ `Please use a different attribute name.`
551
+ ]);
552
+ }
553
+ if (value && typeof value === "object") {
554
+ if (value.type === RUNTIME_LOGIC) {
555
+ // Discard runtime logic for security
556
+ result[key] = "";
557
+ } else if (value.type === STATIC_LOGIC) {
558
+ try {
559
+ result[key] = await evaluator.execute(value.code);
560
+ } catch (err) {
561
+ transpilerError([
562
+ `<$red:Logic Error (Argument):$> ${err.message}{line}`,
563
+ `<$yellow:Code:$> <$blue:${value.code}$>{line}`
564
+ ]);
565
+ }
566
+ } else {
567
+ result[key] = value;
568
+ }
569
+ } else {
570
+ result[key] = value;
571
+ }
572
+ }
573
+ return result;
574
+ }
575
+
290
576
  export default transpiler;