sommark 4.3.0 → 4.5.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/cli/cli.mjs CHANGED
@@ -12,6 +12,7 @@ import { printOutput, printLex, printParse } from "./commands/print.js";
12
12
  import { runInit } from "./commands/init.js";
13
13
  import { runShow } from "./commands/show.js";
14
14
  import { runColor } from "./commands/color.js";
15
+ import { runBundle } from "./commands/bundle.js";
15
16
  import { extensions } from "./constants.js";
16
17
 
17
18
  // ========================================================================== //
@@ -57,6 +58,14 @@ async function main() {
57
58
  return;
58
59
  }
59
60
 
61
+ // 4.5. Bundle
62
+ if (command === "bundle") {
63
+ const targetDir = args.find(a => !a.startsWith("--") && a !== "bundle");
64
+ const flags = args.filter(a => a.startsWith("--"));
65
+ await runBundle(targetDir, flags);
66
+ return;
67
+ }
68
+
60
69
 
61
70
 
62
71
  // 5. Show
@@ -0,0 +1,144 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { cliError, formatMessage } from "../../core/errors.js";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ const DIST_SRC = path.resolve(__dirname, "../../dist");
9
+
10
+ const BUNDLES = {
11
+ "--lite": { file: "sommark.browser.lite.js", label: "Lite bundle", note: "no WASM — static/runtime blocks disabled" },
12
+ "--only-lexer": { file: "sommark.lexer.js", label: "Lexer bundle", note: "lexSync, lex, TOKEN_TYPES, labels only" },
13
+ "--only-parser": { file: "sommark.parser.js", label: "Parser bundle", note: "lexSync, lex, parseSync, parse, TOKEN_TYPES, labels only" },
14
+ };
15
+
16
+ async function copyDir(src, dest) {
17
+ await fs.mkdir(dest, { recursive: true });
18
+ const entries = await fs.readdir(src, { withFileTypes: true });
19
+ for (const entry of entries) {
20
+ const srcPath = path.join(src, entry.name);
21
+ const destPath = path.join(dest, entry.name);
22
+ if (entry.isDirectory()) {
23
+ await copyDir(srcPath, destPath);
24
+ } else {
25
+ await fs.copyFile(srcPath, destPath);
26
+ }
27
+ }
28
+ }
29
+
30
+ async function getDirSize(dir) {
31
+ let total = 0;
32
+ const entries = await fs.readdir(dir, { withFileTypes: true });
33
+ for (const entry of entries) {
34
+ const full = path.join(dir, entry.name);
35
+ if (entry.isDirectory()) {
36
+ total += await getDirSize(full);
37
+ } else {
38
+ const stat = await fs.stat(full);
39
+ total += stat.size;
40
+ }
41
+ }
42
+ return total;
43
+ }
44
+
45
+ async function ensureDir(outDir) {
46
+ let exists = false;
47
+ try {
48
+ const stat = await fs.stat(outDir);
49
+ if (!stat.isDirectory()) {
50
+ cliError([
51
+ `{line}<$red:Target path$> <$yellow:'${outDir}'$> <$red:already exists but is a file, not a directory.$>{line}`
52
+ ]);
53
+ }
54
+ exists = true;
55
+ } catch (err) {
56
+ if (err.code !== "ENOENT") throw err;
57
+ }
58
+ if (!exists) {
59
+ try {
60
+ await fs.mkdir(outDir, { recursive: true });
61
+ } catch (err) {
62
+ cliError([
63
+ `{line}<$red:Could not create directory$> <$yellow:'${outDir}'$>{N}`,
64
+ `<$blue:${err.message}$>{line}`
65
+ ]);
66
+ }
67
+ }
68
+ }
69
+
70
+ export async function runBundle(targetDir, flags = []) {
71
+ const outDir = targetDir ? path.resolve(process.cwd(), targetDir) : process.cwd();
72
+
73
+ // Partial bundle (--lite, --only-lexer, --only-parser)
74
+ const partialFlag = flags.find(f => BUNDLES[f]);
75
+ if (partialFlag) {
76
+ const { file, label, note } = BUNDLES[partialFlag];
77
+ const src = path.resolve(DIST_SRC, file);
78
+
79
+ try {
80
+ await fs.access(src);
81
+ } catch {
82
+ cliError([
83
+ `{line}<$red:${label} not found at$> <$yellow:'${src}'$>{N}`,
84
+ `<$red:Your SomMark installation may be incomplete or corrupted.$>{line}`
85
+ ]);
86
+ }
87
+
88
+ await ensureDir(outDir);
89
+
90
+ const dest = path.join(outDir, file);
91
+ try {
92
+ await fs.copyFile(src, dest);
93
+ } catch (err) {
94
+ cliError([
95
+ `{line}<$red:Failed to copy ${label.toLowerCase()} to$> <$yellow:'${dest}'$>{N}`,
96
+ `<$blue:${err.message}$>{line}`
97
+ ]);
98
+ }
99
+
100
+ const stats = await fs.stat(dest);
101
+ const date = new Date().toLocaleString();
102
+ console.log(formatMessage(
103
+ [
104
+ `{line}[<$yellow: STATUS$> : <$green: SUCCESS$>]{line}`,
105
+ `<$blue:${label}$> <$yellow:'${file}'$> <$blue:copied to$> <$yellow:'${outDir}'$>{N}`,
106
+ `<$blue:Size:$> <$yellow:${(stats.size / 1024).toFixed(1)} KB$> <$yellow:(${note})$>{N}`,
107
+ `<$blue:Date:$> <$yellow:${date}$>{line}`
108
+ ].join("")
109
+ ));
110
+ return;
111
+ }
112
+
113
+ // Full: copy entire dist folder
114
+ try {
115
+ await fs.access(DIST_SRC);
116
+ } catch {
117
+ cliError([
118
+ `{line}<$red:dist folder not found at$> <$yellow:'${DIST_SRC}'$>{N}`,
119
+ `<$red:Your SomMark installation may be incomplete or corrupted.$>{line}`
120
+ ]);
121
+ }
122
+
123
+ await ensureDir(outDir);
124
+
125
+ try {
126
+ await copyDir(DIST_SRC, outDir);
127
+ } catch (err) {
128
+ cliError([
129
+ `{line}<$red:Failed to copy bundle to$> <$yellow:'${outDir}'$>{N}`,
130
+ `<$blue:${err.message}$>{line}`
131
+ ]);
132
+ }
133
+
134
+ const totalSize = await getDirSize(outDir);
135
+ const date = new Date().toLocaleString();
136
+ console.log(formatMessage(
137
+ [
138
+ `{line}[<$yellow: STATUS$> : <$green: SUCCESS$>]{line}`,
139
+ `<$blue:Bundle copied to$> <$yellow:'${outDir}'$>{N}`,
140
+ `<$blue:Total size:$> <$yellow:${(totalSize / 1024).toFixed(1)} KB$>{N}`,
141
+ `<$blue:Date:$> <$yellow:${date}$>{line}`
142
+ ].join("")
143
+ ));
144
+ }
@@ -22,6 +22,10 @@ export function getHelp(unknown_option = true) {
22
22
  "{N} <$green:show config [file]$> <$cyan: Display the configuration data (for a specific file or CWD)$>",
23
23
  "{N} <$green:show --path-config [file]$> <$cyan: Display the absolute path to the active config file$>",
24
24
  "{N} <$green:color on|off$> <$cyan: Help on enabling colors via Environment Variables$>",
25
+ "{N} <$green:bundle [dir]$> <$cyan: Copy the full browser bundle (JS + WASM) to a directory$>",
26
+ "{N} <$green:bundle [dir] --lite$> <$cyan: Lite bundle — no WASM, static/runtime blocks disabled$>",
27
+ "{N} <$green:bundle [dir] --only-lexer$> <$cyan: Lexer only — lexSync, lex, TOKEN_TYPES, labels$>",
28
+ "{N} <$green:bundle [dir] --only-parser$> <$cyan: Parser only — lexSync, parseSync and above$>",
25
29
 
26
30
  "{N}{N}<$yellow:Transpilation Options:$>",
27
31
  "{N}<$yellow:Usage:$> <$blue:sommark [option] [targetFile] [option] [outputFile] [outputDir]$>",
package/cli/constants.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  /** @type {Array<string>} List of recognized CLI flags and commands. */
7
- export const options = ["-v", "--version", "-h", "--help", "--html", "--markdown", "--mdx", "--json", "--jsonc", "--text", "--xml", "--print", "-p", "--lex", "--parse", "list"];
7
+ export const options = ["-v", "--version", "-h", "--help", "--html", "--markdown", "--mdx", "--json", "--jsonc", "--text", "--xml", "--print", "-p", "--lex", "--parse", "list", "bundle"];
8
8
 
9
9
  /** @type {Object<string, string>} Map of output formats to their respective file extensions. */
10
10
  export const extensions = {
@@ -0,0 +1,44 @@
1
+ // Lite-mode stub — same interface as evaluator.js but without QuickJS/WASM.
2
+ // Static and runtime blocks throw a clear error instead of executing.
3
+
4
+ const LITE_ERROR =
5
+ "[SomMark lite] static ${}$ and runtime ${}$ blocks are not supported in lite mode. " +
6
+ "Use the full SomMark bundle to enable JS evaluation.";
7
+
8
+ export function setCompilerClass(_cls) {}
9
+
10
+ class EvaluatorStub {
11
+ setDefaultFs(_fs) {}
12
+
13
+ get active() {
14
+ return this;
15
+ }
16
+
17
+ async init(_baseDir, _security, _settings, _mapperFile) {}
18
+
19
+ destroy() {}
20
+
21
+ pushScope() {}
22
+
23
+ async popScope() {}
24
+
25
+ inject(_vars) {}
26
+
27
+ async execute(_code) {
28
+ throw new Error(LITE_ERROR);
29
+ }
30
+
31
+ hasDynamicTag(_id) {
32
+ return false;
33
+ }
34
+
35
+ getDynamicTagOptions(_id) {
36
+ return null;
37
+ }
38
+
39
+ async executeDynamicTag(_id, _payload) {
40
+ throw new Error(LITE_ERROR);
41
+ }
42
+ }
43
+
44
+ export default new EvaluatorStub();
@@ -18,7 +18,7 @@ export function registerHostSettings(settings) {
18
18
  hostSettings = settings || {};
19
19
  }
20
20
 
21
- const version = "4.3.0";
21
+ const version = "4.5.0";
22
22
 
23
23
  const SomMark = {
24
24
  version,
@@ -45,7 +45,7 @@ function getNodeText(node) {
45
45
  * @param {Object} mapper_file - The rules for how to convert each node.
46
46
  * @returns {Promise<string>} - The final text for this node.
47
47
  */
48
- async function generateOutput(ast, i, format, mapper_file, security = {}, parentId = null, generateRuntimeOutput = false, hideRuntimeOutput = false, instance = null) {
48
+ async function generateOutput(ast, i, format, mapper_file, security = {}, parentId = null, generateRuntimeOutput = false, hideRuntimeOutput = false, instance = null, idState = null) {
49
49
  const node = Array.isArray(ast) ? ast[i] : ast;
50
50
  if (!node) return "";
51
51
 
@@ -60,7 +60,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
60
60
  if (node.body) {
61
61
  evaluator.pushScope();
62
62
  for (let j = 0; j < node.body.length; j++) {
63
- bodyOutput += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
63
+ bodyOutput += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
64
64
  }
65
65
  await evaluator.popScope();
66
66
  }
@@ -168,7 +168,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
168
168
  });
169
169
 
170
170
  for (let j = 0; j < cleanedBody.length; j++) {
171
- output += await generateOutput(cleanedBody, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
171
+ output += await generateOutput(cleanedBody, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
172
172
  }
173
173
 
174
174
  await evaluator.popScope();
@@ -191,7 +191,12 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
191
191
 
192
192
  const hasRuntime = node.body?.some(child => child.type === RUNTIME_LOGIC);
193
193
  if (hasRuntime) {
194
- secretId = `sommark-${node.id.toLowerCase()}-${randomBytesHex(4)}`;
194
+ if (idState?.mode === 'replay') {
195
+ secretId = idState.ids[idState.idx++] ?? `sommark-${node.id.toLowerCase()}-${randomBytesHex(4)}`;
196
+ } else {
197
+ secretId = `sommark-${node.id.toLowerCase()}-${randomBytesHex(4)}`;
198
+ if (idState?.mode === 'record') idState.ids.push(secretId);
199
+ }
195
200
  }
196
201
  }
197
202
 
@@ -247,7 +252,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
247
252
  let resolvedBody = "";
248
253
  evaluator.pushScope();
249
254
  for (let j = 0; j < node.body.length; j++) {
250
- resolvedBody += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
255
+ resolvedBody += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
251
256
  }
252
257
  await evaluator.popScope();
253
258
  content = dedentBy(resolvedBody, node.range?.start?.character || 0);
@@ -257,7 +262,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
257
262
  let childrenOutput = "";
258
263
  if (node.body) {
259
264
  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);
265
+ childrenOutput += await generateOutput(node.body, j, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
261
266
  }
262
267
  }
263
268
  return childrenOutput;
@@ -369,7 +374,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
369
374
 
370
375
  case FOR_EACH:
371
376
  case BLOCK:
372
- bodyOutput = await generateOutput(body_node, 0, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
377
+ bodyOutput = await generateOutput(body_node, 0, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
373
378
  break;
374
379
 
375
380
  case RUNTIME_LOGIC:
@@ -488,6 +493,31 @@ export async function transpiler(optionsOrAst, format, mapperFile) {
488
493
  settings.fs = instance.fs;
489
494
  }
490
495
 
496
+ const generateRuntimeOutput = optionsOrAst?.generateRuntimeOutput || false;
497
+ const hideRuntimeOutput = optionsOrAst?.hideRuntimeOutput || false;
498
+ const dualOutput = optionsOrAst?.dualOutput || false;
499
+
500
+ if (dualOutput && (generateRuntimeOutput || hideRuntimeOutput)) {
501
+ const flags = [
502
+ generateRuntimeOutput && "\x1b[36mgenerateRuntimeOutput\x1b[0m",
503
+ hideRuntimeOutput && "\x1b[36mhideRuntimeOutput\x1b[0m"
504
+ ].filter(Boolean).join(" and ");
505
+ console.warn(
506
+ `\n[SomMark] \x1b[33m⚠ Ignored options when dualOutput is true\x1b[0m\n` +
507
+ ` ${flags} ${generateRuntimeOutput && hideRuntimeOutput ? "are" : "is"} ignored when \x1b[32mdualOutput: true\x1b[0m is set.\n` +
508
+ ` \x1b[2mdualOutput manages both HTML and JS passes internally — no need to set those flags.\x1b[0m\n`
509
+ );
510
+ } else if (generateRuntimeOutput && hideRuntimeOutput) {
511
+ console.warn(
512
+ "\n[SomMark] \x1b[33m⚠ Conflicting options — output will be empty\x1b[0m\n" +
513
+ " \x1b[36mgenerateRuntimeOutput: true\x1b[0m → outputs only JS, suppresses all HTML\n" +
514
+ " \x1b[36mhideRuntimeOutput: true\x1b[0m → suppresses all JS output\n" +
515
+ " Together they cancel each other out and produce nothing.\n" +
516
+ " \x1b[2mHint: use one at a time, or \x1b[0m\x1b[32mdualOutput: true\x1b[0m\x1b[2m to get [html, js] in one call.\x1b[0m\n"
517
+ );
518
+ return "";
519
+ }
520
+
491
521
  // Initialize Logic Sandbox
492
522
  await evaluator.init(null, security, settings, targetMapper);
493
523
  // Inject global data
@@ -499,8 +529,63 @@ export async function transpiler(optionsOrAst, format, mapperFile) {
499
529
  let output = "";
500
530
  let prev_body_node = null;
501
531
  let prev_was_silent = false;
502
- const generateRuntimeOutput = optionsOrAst?.generateRuntimeOutput || false;
503
- const hideRuntimeOutput = optionsOrAst?.hideRuntimeOutput || false;
532
+
533
+ if (dualOutput) {
534
+ const idState = { mode: 'record', ids: [], idx: 0 };
535
+
536
+ // HTML pass — generate HTML, record element IDs for runtime blocks
537
+ let htmlOutput = "";
538
+ try {
539
+ for (let i = 0; i < body.length; i++) {
540
+ const node = body[i];
541
+ const blockOutput = await generateOutput(body, i, targetFormat, targetMapper, security, null, false, true, instance, idState);
542
+ let finalBlockOutput = blockOutput;
543
+ if (prev_was_silent && node.type === TEXT) finalBlockOutput = finalBlockOutput.replace(/^\n/, "");
544
+ if (finalBlockOutput) {
545
+ htmlOutput += finalBlockOutput;
546
+ prev_was_silent = false;
547
+ } else {
548
+ prev_was_silent = true;
549
+ if ((node.type === COMMENT || node.type === COMMENT_BLOCK) && targetMapper?.options?.removeComments) {
550
+ const nextNode = body[i + 1];
551
+ if (nextNode && nextNode.type === TEXT && (nextNode.text === "\n" || nextNode.text === "\r\n")) i++;
552
+ }
553
+ }
554
+ }
555
+ } finally {
556
+ evaluator.destroy();
557
+ }
558
+
559
+ // JS pass — replay the same IDs so querySelector targets match HTML
560
+ idState.mode = 'replay';
561
+ idState.idx = 0;
562
+ prev_was_silent = false;
563
+
564
+ await evaluator.init(null, security, settings, targetMapper);
565
+ evaluator.inject(placeholders);
566
+ evaluator.inject(variables);
567
+
568
+ let jsOutput = "";
569
+ try {
570
+ for (let i = 0; i < body.length; i++) {
571
+ const node = body[i];
572
+ const blockOutput = await generateOutput(body, i, targetFormat, targetMapper, security, null, true, false, instance, idState);
573
+ let finalBlockOutput = blockOutput;
574
+ if (prev_was_silent && node.type === TEXT) finalBlockOutput = finalBlockOutput.replace(/^\n/, "");
575
+ if (finalBlockOutput) {
576
+ jsOutput += finalBlockOutput;
577
+ prev_was_silent = false;
578
+ } else {
579
+ prev_was_silent = true;
580
+ }
581
+ }
582
+ } finally {
583
+ evaluator.destroy();
584
+ }
585
+
586
+ return [htmlOutput.trim(), jsOutput.trim()];
587
+ }
588
+
504
589
  try {
505
590
  for (let i = 0; i < body.length; i++) {
506
591
  const node = body[i];
@@ -9184,7 +9184,7 @@ function registerHostSettings(settings) {
9184
9184
  hostSettings = settings || {};
9185
9185
  }
9186
9186
 
9187
- const version = "4.3.0";
9187
+ const version = "4.5.0";
9188
9188
 
9189
9189
  const SomMark$1 = {
9190
9190
  version,
@@ -10577,7 +10577,7 @@ function getNodeText$1(node) {
10577
10577
  * @param {Object} mapper_file - The rules for how to convert each node.
10578
10578
  * @returns {Promise<string>} - The final text for this node.
10579
10579
  */
10580
- async function generateOutput(ast, i, format, mapper_file, security = {}, parentId = null, generateRuntimeOutput = false, hideRuntimeOutput = false, instance = null) {
10580
+ async function generateOutput(ast, i, format, mapper_file, security = {}, parentId = null, generateRuntimeOutput = false, hideRuntimeOutput = false, instance = null, idState = null) {
10581
10581
  const node = Array.isArray(ast) ? ast[i] : ast;
10582
10582
  if (!node) return "";
10583
10583
 
@@ -10592,7 +10592,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
10592
10592
  if (node.body) {
10593
10593
  Evaluator$1.pushScope();
10594
10594
  for (let j = 0; j < node.body.length; j++) {
10595
- bodyOutput += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
10595
+ bodyOutput += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
10596
10596
  }
10597
10597
  await Evaluator$1.popScope();
10598
10598
  }
@@ -10700,7 +10700,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
10700
10700
  });
10701
10701
 
10702
10702
  for (let j = 0; j < cleanedBody.length; j++) {
10703
- output += await generateOutput(cleanedBody, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
10703
+ output += await generateOutput(cleanedBody, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
10704
10704
  }
10705
10705
 
10706
10706
  await Evaluator$1.popScope();
@@ -10723,7 +10723,12 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
10723
10723
 
10724
10724
  const hasRuntime = node.body?.some(child => child.type === RUNTIME_LOGIC);
10725
10725
  if (hasRuntime) {
10726
- secretId = `sommark-${node.id.toLowerCase()}-${randomBytesHex(4)}`;
10726
+ if (idState?.mode === 'replay') {
10727
+ secretId = idState.ids[idState.idx++] ?? `sommark-${node.id.toLowerCase()}-${randomBytesHex(4)}`;
10728
+ } else {
10729
+ secretId = `sommark-${node.id.toLowerCase()}-${randomBytesHex(4)}`;
10730
+ if (idState?.mode === 'record') idState.ids.push(secretId);
10731
+ }
10727
10732
  }
10728
10733
  }
10729
10734
 
@@ -10779,7 +10784,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
10779
10784
  let resolvedBody = "";
10780
10785
  Evaluator$1.pushScope();
10781
10786
  for (let j = 0; j < node.body.length; j++) {
10782
- resolvedBody += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
10787
+ resolvedBody += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
10783
10788
  }
10784
10789
  await Evaluator$1.popScope();
10785
10790
  content = dedentBy(resolvedBody, node.range?.start?.character || 0);
@@ -10789,7 +10794,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
10789
10794
  let childrenOutput = "";
10790
10795
  if (node.body) {
10791
10796
  for (let j = 0; j < node.body.length; j++) {
10792
- childrenOutput += await generateOutput(node.body, j, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
10797
+ childrenOutput += await generateOutput(node.body, j, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
10793
10798
  }
10794
10799
  }
10795
10800
  return childrenOutput;
@@ -10900,7 +10905,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
10900
10905
 
10901
10906
  case FOR_EACH:
10902
10907
  case BLOCK:
10903
- bodyOutput = await generateOutput(body_node, 0, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
10908
+ bodyOutput = await generateOutput(body_node, 0, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
10904
10909
  break;
10905
10910
 
10906
10911
  case RUNTIME_LOGIC:
@@ -11019,6 +11024,31 @@ async function transpiler(optionsOrAst, format, mapperFile) {
11019
11024
  settings.fs = instance.fs;
11020
11025
  }
11021
11026
 
11027
+ const generateRuntimeOutput = optionsOrAst?.generateRuntimeOutput || false;
11028
+ const hideRuntimeOutput = optionsOrAst?.hideRuntimeOutput || false;
11029
+ const dualOutput = optionsOrAst?.dualOutput || false;
11030
+
11031
+ if (dualOutput && (generateRuntimeOutput || hideRuntimeOutput)) {
11032
+ const flags = [
11033
+ generateRuntimeOutput && "\x1b[36mgenerateRuntimeOutput\x1b[0m",
11034
+ hideRuntimeOutput && "\x1b[36mhideRuntimeOutput\x1b[0m"
11035
+ ].filter(Boolean).join(" and ");
11036
+ console.warn(
11037
+ `\n[SomMark] \x1b[33m⚠ Ignored options when dualOutput is true\x1b[0m\n` +
11038
+ ` ${flags} ${generateRuntimeOutput && hideRuntimeOutput ? "are" : "is"} ignored when \x1b[32mdualOutput: true\x1b[0m is set.\n` +
11039
+ ` \x1b[2mdualOutput manages both HTML and JS passes internally — no need to set those flags.\x1b[0m\n`
11040
+ );
11041
+ } else if (generateRuntimeOutput && hideRuntimeOutput) {
11042
+ console.warn(
11043
+ "\n[SomMark] \x1b[33m⚠ Conflicting options — output will be empty\x1b[0m\n" +
11044
+ " \x1b[36mgenerateRuntimeOutput: true\x1b[0m → outputs only JS, suppresses all HTML\n" +
11045
+ " \x1b[36mhideRuntimeOutput: true\x1b[0m → suppresses all JS output\n" +
11046
+ " Together they cancel each other out and produce nothing.\n" +
11047
+ " \x1b[2mHint: use one at a time, or \x1b[0m\x1b[32mdualOutput: true\x1b[0m\x1b[2m to get [html, js] in one call.\x1b[0m\n"
11048
+ );
11049
+ return "";
11050
+ }
11051
+
11022
11052
  // Initialize Logic Sandbox
11023
11053
  await Evaluator$1.init(null, security, settings, targetMapper);
11024
11054
  // Inject global data
@@ -11030,8 +11060,63 @@ async function transpiler(optionsOrAst, format, mapperFile) {
11030
11060
  let output = "";
11031
11061
  let prev_body_node = null;
11032
11062
  let prev_was_silent = false;
11033
- const generateRuntimeOutput = optionsOrAst?.generateRuntimeOutput || false;
11034
- const hideRuntimeOutput = optionsOrAst?.hideRuntimeOutput || false;
11063
+
11064
+ if (dualOutput) {
11065
+ const idState = { mode: 'record', ids: [], idx: 0 };
11066
+
11067
+ // HTML pass — generate HTML, record element IDs for runtime blocks
11068
+ let htmlOutput = "";
11069
+ try {
11070
+ for (let i = 0; i < body.length; i++) {
11071
+ const node = body[i];
11072
+ const blockOutput = await generateOutput(body, i, targetFormat, targetMapper, security, null, false, true, instance, idState);
11073
+ let finalBlockOutput = blockOutput;
11074
+ if (prev_was_silent && node.type === TEXT$1) finalBlockOutput = finalBlockOutput.replace(/^\n/, "");
11075
+ if (finalBlockOutput) {
11076
+ htmlOutput += finalBlockOutput;
11077
+ prev_was_silent = false;
11078
+ } else {
11079
+ prev_was_silent = true;
11080
+ if ((node.type === COMMENT || node.type === COMMENT_BLOCK) && targetMapper?.options?.removeComments) {
11081
+ const nextNode = body[i + 1];
11082
+ if (nextNode && nextNode.type === TEXT$1 && (nextNode.text === "\n" || nextNode.text === "\r\n")) i++;
11083
+ }
11084
+ }
11085
+ }
11086
+ } finally {
11087
+ Evaluator$1.destroy();
11088
+ }
11089
+
11090
+ // JS pass — replay the same IDs so querySelector targets match HTML
11091
+ idState.mode = 'replay';
11092
+ idState.idx = 0;
11093
+ prev_was_silent = false;
11094
+
11095
+ await Evaluator$1.init(null, security, settings, targetMapper);
11096
+ Evaluator$1.inject(placeholders);
11097
+ Evaluator$1.inject(variables);
11098
+
11099
+ let jsOutput = "";
11100
+ try {
11101
+ for (let i = 0; i < body.length; i++) {
11102
+ const node = body[i];
11103
+ const blockOutput = await generateOutput(body, i, targetFormat, targetMapper, security, null, true, false, instance, idState);
11104
+ let finalBlockOutput = blockOutput;
11105
+ if (prev_was_silent && node.type === TEXT$1) finalBlockOutput = finalBlockOutput.replace(/^\n/, "");
11106
+ if (finalBlockOutput) {
11107
+ jsOutput += finalBlockOutput;
11108
+ prev_was_silent = false;
11109
+ } else {
11110
+ prev_was_silent = true;
11111
+ }
11112
+ }
11113
+ } finally {
11114
+ Evaluator$1.destroy();
11115
+ }
11116
+
11117
+ return [htmlOutput.trim(), jsOutput.trim()];
11118
+ }
11119
+
11035
11120
  try {
11036
11121
  for (let i = 0; i < body.length; i++) {
11037
11122
  const node = body[i];
@@ -14095,7 +14180,7 @@ class SomMark {
14095
14180
  * @param {string} [options.baseDir=null] - The base directory for resolving relative paths.
14096
14181
  */
14097
14182
  constructor(options = {}) {
14098
- const { src, ast = null, format, mapperFile = null, filename = "anonymous", removeComments = true, placeholders = {}, customProps = [], fallbackTarget = "style", outputValidator = null, importAliases = {}, importStack = [], baseDir = null, moduleCache = null, showSpinner = true, security = {}, generateRuntimeOutput = false, hideRuntimeOutput = false, moduleIdentityToken = null } = options;
14183
+ const { src, ast = null, format, mapperFile = null, filename = "anonymous", removeComments = true, placeholders = {}, customProps = [], fallbackTarget = "style", outputValidator = null, importAliases = {}, importStack = [], baseDir = null, moduleCache = null, showSpinner = true, security = {}, generateRuntimeOutput = false, hideRuntimeOutput = false, dualOutput = false, moduleIdentityToken = null } = options;
14099
14184
  this.rawSettings = options;
14100
14185
  this.src = src;
14101
14186
  this.ast = ast;
@@ -14107,6 +14192,7 @@ class SomMark {
14107
14192
  this.customProps = customProps;
14108
14193
  this.generateRuntimeOutput = generateRuntimeOutput;
14109
14194
  this.hideRuntimeOutput = hideRuntimeOutput;
14195
+ this.dualOutput = dualOutput;
14110
14196
  this.cwd = options.baseDir || (options.files ? "/" : defaultCwd);
14111
14197
  this.fs = options.fs
14112
14198
  || (options.files ? new VirtualFS(options.files) : null)
@@ -14322,6 +14408,7 @@ class SomMark {
14322
14408
  settings: this.rawSettings,
14323
14409
  generateRuntimeOutput: this.generateRuntimeOutput,
14324
14410
  hideRuntimeOutput: this.hideRuntimeOutput,
14411
+ dualOutput: this.dualOutput,
14325
14412
  instance: this
14326
14413
  });
14327
14414