sommark 4.4.0 → 4.5.1

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.
@@ -18,7 +18,7 @@ export function registerHostSettings(settings) {
18
18
  hostSettings = settings || {};
19
19
  }
20
20
 
21
- const version = "4.4.0";
21
+ const version = "4.5.1";
22
22
 
23
23
  const SomMark = {
24
24
  version,
package/core/modules.js CHANGED
@@ -366,6 +366,7 @@ export async function resolveModules(ast, context) {
366
366
  security: context.instance.security,
367
367
  showSpinner: context.instance.showSpinner,
368
368
  importStack: [...stack, absFilename],
369
+ moduleIdentityToken: context.instance.moduleIdentityToken,
369
370
  moduleCache: context.instance.moduleCache
370
371
  });
371
372
 
@@ -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.4.0";
9187
+ const version = "4.5.1";
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];
@@ -13733,6 +13818,7 @@ async function resolveModules(ast, context) {
13733
13818
  security: context.instance.security,
13734
13819
  showSpinner: context.instance.showSpinner,
13735
13820
  importStack: [...stack, absFilename],
13821
+ moduleIdentityToken: context.instance.moduleIdentityToken,
13736
13822
  moduleCache: context.instance.moduleCache
13737
13823
  });
13738
13824
 
@@ -14095,7 +14181,7 @@ class SomMark {
14095
14181
  * @param {string} [options.baseDir=null] - The base directory for resolving relative paths.
14096
14182
  */
14097
14183
  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;
14184
+ 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
14185
  this.rawSettings = options;
14100
14186
  this.src = src;
14101
14187
  this.ast = ast;
@@ -14107,6 +14193,7 @@ class SomMark {
14107
14193
  this.customProps = customProps;
14108
14194
  this.generateRuntimeOutput = generateRuntimeOutput;
14109
14195
  this.hideRuntimeOutput = hideRuntimeOutput;
14196
+ this.dualOutput = dualOutput;
14110
14197
  this.cwd = options.baseDir || (options.files ? "/" : defaultCwd);
14111
14198
  this.fs = options.fs
14112
14199
  || (options.files ? new VirtualFS(options.files) : null)
@@ -14322,6 +14409,7 @@ class SomMark {
14322
14409
  settings: this.rawSettings,
14323
14410
  generateRuntimeOutput: this.generateRuntimeOutput,
14324
14411
  hideRuntimeOutput: this.hideRuntimeOutput,
14412
+ dualOutput: this.dualOutput,
14325
14413
  instance: this
14326
14414
  });
14327
14415
 
@@ -9485,7 +9485,7 @@ function getNodeText$1(node) {
9485
9485
  * @param {Object} mapper_file - The rules for how to convert each node.
9486
9486
  * @returns {Promise<string>} - The final text for this node.
9487
9487
  */
9488
- async function generateOutput(ast, i, format, mapper_file, security = {}, parentId = null, generateRuntimeOutput = false, hideRuntimeOutput = false, instance = null) {
9488
+ async function generateOutput(ast, i, format, mapper_file, security = {}, parentId = null, generateRuntimeOutput = false, hideRuntimeOutput = false, instance = null, idState = null) {
9489
9489
  const node = Array.isArray(ast) ? ast[i] : ast;
9490
9490
  if (!node) return "";
9491
9491
 
@@ -9500,7 +9500,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
9500
9500
  if (node.body) {
9501
9501
  Evaluator.pushScope();
9502
9502
  for (let j = 0; j < node.body.length; j++) {
9503
- bodyOutput += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
9503
+ bodyOutput += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
9504
9504
  }
9505
9505
  await Evaluator.popScope();
9506
9506
  }
@@ -9608,7 +9608,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
9608
9608
  });
9609
9609
 
9610
9610
  for (let j = 0; j < cleanedBody.length; j++) {
9611
- output += await generateOutput(cleanedBody, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
9611
+ output += await generateOutput(cleanedBody, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
9612
9612
  }
9613
9613
 
9614
9614
  await Evaluator.popScope();
@@ -9631,7 +9631,12 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
9631
9631
 
9632
9632
  const hasRuntime = node.body?.some(child => child.type === RUNTIME_LOGIC);
9633
9633
  if (hasRuntime) {
9634
- secretId = `sommark-${node.id.toLowerCase()}-${randomBytesHex(4)}`;
9634
+ if (idState?.mode === 'replay') {
9635
+ secretId = idState.ids[idState.idx++] ?? `sommark-${node.id.toLowerCase()}-${randomBytesHex(4)}`;
9636
+ } else {
9637
+ secretId = `sommark-${node.id.toLowerCase()}-${randomBytesHex(4)}`;
9638
+ if (idState?.mode === 'record') idState.ids.push(secretId);
9639
+ }
9635
9640
  }
9636
9641
  }
9637
9642
 
@@ -9687,7 +9692,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
9687
9692
  let resolvedBody = "";
9688
9693
  Evaluator.pushScope();
9689
9694
  for (let j = 0; j < node.body.length; j++) {
9690
- resolvedBody += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
9695
+ resolvedBody += await generateOutput(node.body, j, format, mapper_file, security, parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
9691
9696
  }
9692
9697
  await Evaluator.popScope();
9693
9698
  content = dedentBy(resolvedBody, node.range?.start?.character || 0);
@@ -9697,7 +9702,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
9697
9702
  let childrenOutput = "";
9698
9703
  if (node.body) {
9699
9704
  for (let j = 0; j < node.body.length; j++) {
9700
- childrenOutput += await generateOutput(node.body, j, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
9705
+ childrenOutput += await generateOutput(node.body, j, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
9701
9706
  }
9702
9707
  }
9703
9708
  return childrenOutput;
@@ -9808,7 +9813,7 @@ async function generateOutput(ast, i, format, mapper_file, security = {}, parent
9808
9813
 
9809
9814
  case FOR_EACH:
9810
9815
  case BLOCK:
9811
- bodyOutput = await generateOutput(body_node, 0, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance);
9816
+ bodyOutput = await generateOutput(body_node, 0, format, mapper_file, security, secretId || parentId, generateRuntimeOutput, hideRuntimeOutput, instance, idState);
9812
9817
  break;
9813
9818
 
9814
9819
  case RUNTIME_LOGIC:
@@ -9927,6 +9932,31 @@ async function transpiler(optionsOrAst, format, mapperFile) {
9927
9932
  settings.fs = instance.fs;
9928
9933
  }
9929
9934
 
9935
+ const generateRuntimeOutput = optionsOrAst?.generateRuntimeOutput || false;
9936
+ const hideRuntimeOutput = optionsOrAst?.hideRuntimeOutput || false;
9937
+ const dualOutput = optionsOrAst?.dualOutput || false;
9938
+
9939
+ if (dualOutput && (generateRuntimeOutput || hideRuntimeOutput)) {
9940
+ const flags = [
9941
+ generateRuntimeOutput && "\x1b[36mgenerateRuntimeOutput\x1b[0m",
9942
+ hideRuntimeOutput && "\x1b[36mhideRuntimeOutput\x1b[0m"
9943
+ ].filter(Boolean).join(" and ");
9944
+ console.warn(
9945
+ `\n[SomMark] \x1b[33m⚠ Ignored options when dualOutput is true\x1b[0m\n` +
9946
+ ` ${flags} ${generateRuntimeOutput && hideRuntimeOutput ? "are" : "is"} ignored when \x1b[32mdualOutput: true\x1b[0m is set.\n` +
9947
+ ` \x1b[2mdualOutput manages both HTML and JS passes internally — no need to set those flags.\x1b[0m\n`
9948
+ );
9949
+ } else if (generateRuntimeOutput && hideRuntimeOutput) {
9950
+ console.warn(
9951
+ "\n[SomMark] \x1b[33m⚠ Conflicting options — output will be empty\x1b[0m\n" +
9952
+ " \x1b[36mgenerateRuntimeOutput: true\x1b[0m → outputs only JS, suppresses all HTML\n" +
9953
+ " \x1b[36mhideRuntimeOutput: true\x1b[0m → suppresses all JS output\n" +
9954
+ " Together they cancel each other out and produce nothing.\n" +
9955
+ " \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"
9956
+ );
9957
+ return "";
9958
+ }
9959
+
9930
9960
  // Initialize Logic Sandbox
9931
9961
  await Evaluator.init(null, security, settings, targetMapper);
9932
9962
  // Inject global data
@@ -9938,8 +9968,63 @@ async function transpiler(optionsOrAst, format, mapperFile) {
9938
9968
  let output = "";
9939
9969
  let prev_body_node = null;
9940
9970
  let prev_was_silent = false;
9941
- const generateRuntimeOutput = optionsOrAst?.generateRuntimeOutput || false;
9942
- const hideRuntimeOutput = optionsOrAst?.hideRuntimeOutput || false;
9971
+
9972
+ if (dualOutput) {
9973
+ const idState = { mode: 'record', ids: [], idx: 0 };
9974
+
9975
+ // HTML pass — generate HTML, record element IDs for runtime blocks
9976
+ let htmlOutput = "";
9977
+ try {
9978
+ for (let i = 0; i < body.length; i++) {
9979
+ const node = body[i];
9980
+ const blockOutput = await generateOutput(body, i, targetFormat, targetMapper, security, null, false, true, instance, idState);
9981
+ let finalBlockOutput = blockOutput;
9982
+ if (prev_was_silent && node.type === TEXT$1) finalBlockOutput = finalBlockOutput.replace(/^\n/, "");
9983
+ if (finalBlockOutput) {
9984
+ htmlOutput += finalBlockOutput;
9985
+ prev_was_silent = false;
9986
+ } else {
9987
+ prev_was_silent = true;
9988
+ if ((node.type === COMMENT || node.type === COMMENT_BLOCK) && targetMapper?.options?.removeComments) {
9989
+ const nextNode = body[i + 1];
9990
+ if (nextNode && nextNode.type === TEXT$1 && (nextNode.text === "\n" || nextNode.text === "\r\n")) i++;
9991
+ }
9992
+ }
9993
+ }
9994
+ } finally {
9995
+ Evaluator.destroy();
9996
+ }
9997
+
9998
+ // JS pass — replay the same IDs so querySelector targets match HTML
9999
+ idState.mode = 'replay';
10000
+ idState.idx = 0;
10001
+ prev_was_silent = false;
10002
+
10003
+ await Evaluator.init(null, security, settings, targetMapper);
10004
+ Evaluator.inject(placeholders);
10005
+ Evaluator.inject(variables);
10006
+
10007
+ let jsOutput = "";
10008
+ try {
10009
+ for (let i = 0; i < body.length; i++) {
10010
+ const node = body[i];
10011
+ const blockOutput = await generateOutput(body, i, targetFormat, targetMapper, security, null, true, false, instance, idState);
10012
+ let finalBlockOutput = blockOutput;
10013
+ if (prev_was_silent && node.type === TEXT$1) finalBlockOutput = finalBlockOutput.replace(/^\n/, "");
10014
+ if (finalBlockOutput) {
10015
+ jsOutput += finalBlockOutput;
10016
+ prev_was_silent = false;
10017
+ } else {
10018
+ prev_was_silent = true;
10019
+ }
10020
+ }
10021
+ } finally {
10022
+ Evaluator.destroy();
10023
+ }
10024
+
10025
+ return [htmlOutput.trim(), jsOutput.trim()];
10026
+ }
10027
+
9943
10028
  try {
9944
10029
  for (let i = 0; i < body.length; i++) {
9945
10030
  const node = body[i];
@@ -12641,6 +12726,7 @@ async function resolveModules(ast, context) {
12641
12726
  security: context.instance.security,
12642
12727
  showSpinner: context.instance.showSpinner,
12643
12728
  importStack: [...stack, absFilename],
12729
+ moduleIdentityToken: context.instance.moduleIdentityToken,
12644
12730
  moduleCache: context.instance.moduleCache
12645
12731
  });
12646
12732
 
@@ -13003,7 +13089,7 @@ class SomMark {
13003
13089
  * @param {string} [options.baseDir=null] - The base directory for resolving relative paths.
13004
13090
  */
13005
13091
  constructor(options = {}) {
13006
- 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;
13092
+ 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;
13007
13093
  this.rawSettings = options;
13008
13094
  this.src = src;
13009
13095
  this.ast = ast;
@@ -13015,6 +13101,7 @@ class SomMark {
13015
13101
  this.customProps = customProps;
13016
13102
  this.generateRuntimeOutput = generateRuntimeOutput;
13017
13103
  this.hideRuntimeOutput = hideRuntimeOutput;
13104
+ this.dualOutput = dualOutput;
13018
13105
  this.cwd = options.baseDir || (options.files ? "/" : defaultCwd);
13019
13106
  this.fs = options.fs
13020
13107
  || (options.files ? new VirtualFS(options.files) : null)
@@ -13230,6 +13317,7 @@ class SomMark {
13230
13317
  settings: this.rawSettings,
13231
13318
  generateRuntimeOutput: this.generateRuntimeOutput,
13232
13319
  hideRuntimeOutput: this.hideRuntimeOutput,
13320
+ dualOutput: this.dualOutput,
13233
13321
  instance: this
13234
13322
  });
13235
13323
 
package/index.shared.js CHANGED
@@ -64,7 +64,7 @@ class SomMark {
64
64
  * @param {string} [options.baseDir=null] - The base directory for resolving relative paths.
65
65
  */
66
66
  constructor(options = {}) {
67
- 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;
67
+ 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;
68
68
  this.rawSettings = options;
69
69
  this.src = src;
70
70
  this.ast = ast;
@@ -76,6 +76,7 @@ class SomMark {
76
76
  this.customProps = customProps;
77
77
  this.generateRuntimeOutput = generateRuntimeOutput;
78
78
  this.hideRuntimeOutput = hideRuntimeOutput;
79
+ this.dualOutput = dualOutput;
79
80
  this.cwd = options.baseDir || (options.files ? "/" : defaultCwd);
80
81
  this.fs = options.fs
81
82
  || (options.files ? new VirtualFS(options.files) : null)
@@ -291,6 +292,7 @@ class SomMark {
291
292
  settings: this.rawSettings,
292
293
  generateRuntimeOutput: this.generateRuntimeOutput,
293
294
  hideRuntimeOutput: this.hideRuntimeOutput,
295
+ dualOutput: this.dualOutput,
294
296
  instance: this
295
297
  });
296
298
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sommark",
3
- "version": "4.4.0",
3
+ "version": "4.5.1",
4
4
  "description": "SomMark is a declarative, extensible markup language for structured content that can be converted to HTML, Markdown, MDX, JSON, XML, and more.",
5
5
  "main": "index.js",
6
6
  "files": [