@zvndev/darkmown 0.9.0 → 0.11.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/src/compiler.js CHANGED
@@ -30,6 +30,7 @@ import MarkdownIt from "markdown-it";
30
30
  * @typedef {object} Compilation
31
31
  * @property {Assets} assets
32
32
  * @property {Map<string, unknown>} state Declared state keys → initial values.
33
+ * @property {Set<string>} stores Page-global store names (a subset of state keys).
33
34
  * @property {string[]} warnings Non-fatal authoring hints.
34
35
  * @property {number} sectionCounter Counter for auto-generated section ids.
35
36
  */
@@ -52,6 +53,7 @@ import MarkdownIt from "markdown-it";
52
53
  * @property {string[]} sections Active section-id scope chain.
53
54
  * @property {string | null} loopItem Name of the current reactive loop item, if any.
54
55
  * @property {string} [loopKey] State key of the list being looped, if any.
56
+ * @property {boolean} [loopMeta] Inside a loop body, so `$index`/`$first`/… are valid.
55
57
  * @property {MarkdownIt} [md] Markdown-it instance selected for this file.
56
58
  */
57
59
 
@@ -164,6 +166,7 @@ function createCompilation() {
164
166
  return {
165
167
  assets: { skins: new Set(), scripts: new Set(), files: new Map(), runtime: false },
166
168
  state: new Map(),
169
+ stores: new Set(),
167
170
  warnings: [],
168
171
  sectionCounter: 0
169
172
  };
@@ -329,7 +332,8 @@ function compileBody(lines, ctx) {
329
332
  if (/^@loop\s/.test(line)) {
330
333
  flush();
331
334
  const block = scanBlock(lines, i, /^@loop\s/, "@endloop", ctx.file);
332
- out.push(handleLoop(line, block.body, ctx));
335
+ const split = splitEmptyBranch(block.body);
336
+ out.push(handleLoop(line, split.body, split.empty, ctx));
333
337
  i = block.end;
334
338
  continue;
335
339
  }
@@ -347,6 +351,11 @@ function compileBody(lines, ctx) {
347
351
  out.push(handleState(line, ctx));
348
352
  continue;
349
353
  }
354
+ if (/^:store\s/.test(line)) {
355
+ flush();
356
+ out.push(handleStore(line, ctx));
357
+ continue;
358
+ }
350
359
  if (/^:fetch\s/.test(line)) {
351
360
  flush();
352
361
  out.push(handleFetch(line, ctx));
@@ -450,6 +459,37 @@ function scanBlock(lines, start, openRe, endToken, file) {
450
459
  throw new Error(`Missing ${endToken} for "${lines[start]}" in ${file}`);
451
460
  }
452
461
 
462
+ /**
463
+ * Split a `@loop` body at a top-level `@empty` into the rows body and the empty
464
+ * branch. Honors nested `@loop … @endloop` (so an inner loop's `@empty` is not
465
+ * mistaken for the outer one) and fenced code.
466
+ * @param {string[]} body
467
+ * @returns {{ body: string[], empty: string[] | null }}
468
+ */
469
+ function splitEmptyBranch(body) {
470
+ /** @type {string[]} */
471
+ const rows = [];
472
+ /** @type {string[] | null} */
473
+ let empty = null;
474
+ let target = rows;
475
+ let depth = 0;
476
+ let fence = null;
477
+ for (const line of body) {
478
+ const fenceMatch = line.match(/^(```+|~~~+)/);
479
+ if (fence) {
480
+ if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
481
+ target.push(line);
482
+ continue;
483
+ }
484
+ if (fenceMatch) { fence = fenceMatch[1]; target.push(line); continue; }
485
+ if (/^@loop\s/.test(line)) depth++;
486
+ if (line.trim() === "@endloop") depth--;
487
+ if (depth === 0 && line.trim() === "@empty") { empty = []; target = empty; continue; }
488
+ target.push(line);
489
+ }
490
+ return { body: rows, empty };
491
+ }
492
+
453
493
  /**
454
494
  * Capture the body of a `:::` container up to its matching close, honoring nesting.
455
495
  * @param {string[]} lines
@@ -636,6 +676,7 @@ function handleState(line, ctx) {
636
676
  */
637
677
  function declareState(name, value, ctx) {
638
678
  if (ctx.loopItem) throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
679
+ if (ctx.comp.stores.has(name)) throw new Error(`State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`);
639
680
  const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
640
681
  if (ctx.comp.state.has(key)) throw new Error(`State "${name}" is declared twice in the same scope (${ctx.file})`);
641
682
  ctx.comp.state.set(key, value);
@@ -648,19 +689,36 @@ function declareState(name, value, ctx) {
648
689
  * @param {Ctx} ctx
649
690
  * @returns {string}
650
691
  */
651
- function handleFetch(line, ctx) {
652
- const match = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+?)(\s+when=visible)?\s*$/);
653
- if (!match) {
654
- throw new Error(`Malformed :fetch in ${ctx.file}: ${line}. Use: :fetch posts from "/api/posts.json" [when=visible]`);
655
- }
656
- const key = declareState(match[1], null, ctx);
657
- declareErrorState(key, ctx);
658
- const url = stripQuotes(match[2]);
659
- const when = match[3] ? ` data-wd-fetch-when="visible"` : "";
660
- return `<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"${when}></span>`;
692
+ function handleStore(line, ctx) {
693
+ const match = line.match(/^:store\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+ephemeral)?$/);
694
+ if (!match) throw new Error(`Malformed :store in ${ctx.file}: ${line}. Use: :store name = value [ephemeral]`);
695
+ const value = parseStateValue(match[2]);
696
+ const name = declareStore(match[1], value, ctx);
697
+ const ephemeral = match[3] ? " data-wd-store-ephemeral" : "";
698
+ return `<script type="application/json" data-wd-store="${name}"${ephemeral}>${safeScriptJson(value)}</script>`;
661
699
  }
662
700
 
663
701
  /**
702
+ * Register a page-global store. The bare name is added to `comp.state` so every
703
+ * resolver (interpolation, :if, @loop, :computed, actions) sees it, and tracked
704
+ * in `comp.stores` for collision checks. Never section-scoped.
705
+ * @param {string} name
706
+ * @param {unknown} value
707
+ * @param {Ctx} ctx
708
+ * @returns {string} The store name (also its bare state key).
709
+ */
710
+ function declareStore(name, value, ctx) {
711
+ if (ctx.comp.stores.has(name)) throw new Error(`Store "${name}" is declared twice in ${ctx.file}. Use: :store name = value`);
712
+ if (ctx.comp.state.has(name)) throw new Error(`Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`);
713
+ ctx.comp.stores.add(name);
714
+ ctx.comp.state.set(name, value);
715
+ ctx.comp.assets.runtime = true;
716
+ return name;
717
+ }
718
+
719
+ /**
720
+ * Seed a `<name>_error` state key (null) if absent. Shared by :fetch and the
721
+ * round-trip :form so error fallbacks have a key to bind.
664
722
  * @param {string} key
665
723
  * @param {Ctx} ctx
666
724
  * @returns {void}
@@ -670,6 +728,83 @@ function declareErrorState(key, ctx) {
670
728
  if (!ctx.comp.state.has(errorKey)) ctx.comp.state.set(errorKey, null);
671
729
  }
672
730
 
731
+ const FETCH_USE =
732
+ 'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key]';
733
+
734
+ /**
735
+ * Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
736
+ * Auto-declares four state keys (value/error/loading/empty), seeds them, and
737
+ * emits `data-wd-fetch-*` attributes (url/method/when/timeout/retry/headers/
738
+ * body/deps) consumed by the runtime's `startFetch`.
739
+ * @param {string} line
740
+ * @param {Ctx} ctx
741
+ * @returns {string}
742
+ */
743
+ function handleFetch(line, ctx) {
744
+ const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
745
+ if (!head) throw new Error(`Malformed :fetch in ${ctx.file}: ${line}. ${FETCH_USE}`);
746
+ const name = head[1];
747
+ const url = stripQuotes(head[2]);
748
+ /** @type {Record<string, string>} */
749
+ const opts = {};
750
+ for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
751
+ const kv = part.match(/^([A-Za-z]+)=(.+)$/);
752
+ if (!kv) throw new Error(`Unknown :fetch option "${part}" in ${ctx.file}. ${FETCH_USE}`);
753
+ const optName = kv[1];
754
+ if (!["method", "when", "timeout", "retry", "headers", "body"].includes(optName)) {
755
+ throw new Error(`Unknown :fetch option "${optName}" in ${ctx.file}. ${FETCH_USE}`);
756
+ }
757
+ opts[optName] = stripQuotes(kv[2]);
758
+ }
759
+
760
+ if (opts.method && !["GET", "POST", "PUT", "PATCH", "DELETE"].includes(opts.method.toUpperCase())) {
761
+ throw new Error(`:fetch method "${opts.method}" is not allowed in ${ctx.file}. ${FETCH_USE}`);
762
+ }
763
+ if (opts.when && !["load", "visible"].includes(opts.when)) {
764
+ throw new Error(`:fetch when "${opts.when}" is not allowed in ${ctx.file}. ${FETCH_USE}`);
765
+ }
766
+ for (const n of ["timeout", "retry"]) {
767
+ if (opts[n] !== undefined && !/^\d+$/.test(opts[n])) {
768
+ throw new Error(`:fetch ${n} must be a non-negative integer in ${ctx.file}. ${FETCH_USE}`);
769
+ }
770
+ }
771
+
772
+ // Extract `{ path }` dependency keys from the URL (validated against poison
773
+ // segments). The runtime fills them from state on each (re)fetch.
774
+ /** @type {string[]} */
775
+ const deps = [];
776
+ for (const dep of url.matchAll(/\{\s*([A-Za-z_$][\w$.]*)\s*\}/g)) {
777
+ validatePath(dep[1], ctx, FETCH_USE);
778
+ const headKey = dep[1].split(".")[0];
779
+ if (!deps.includes(headKey)) deps.push(headKey);
780
+ }
781
+
782
+ // Auto-declare the four lifecycle keys. `name` is declared via declareState
783
+ // (collision-checked); the derived keys are seeded directly.
784
+ const key = declareState(name, null, ctx);
785
+ /** @type {Record<string, unknown>} */
786
+ const seeds = { [key]: null };
787
+ for (const [suffix, seed] of [["_error", null], ["_loading", false], ["_empty", false]]) {
788
+ const k = `${key}${suffix}`;
789
+ if (!ctx.comp.state.has(k)) ctx.comp.state.set(k, seed);
790
+ seeds[k] = seed;
791
+ }
792
+
793
+ /** @param {string} n @param {string | null | false | undefined} v */
794
+ const attr = (n, v) => (v != null && v !== false && v !== "" ? ` data-wd-fetch-${n}="${escapeHtml(v)}"` : "");
795
+ const marker =
796
+ `<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"` +
797
+ attr("method", opts.method && opts.method.toUpperCase()) +
798
+ attr("when", opts.when === "visible" ? "visible" : "") +
799
+ attr("timeout", opts.timeout) +
800
+ attr("retry", opts.retry) +
801
+ attr("headers", opts.headers && resolveStateKey(opts.headers, ctx)) +
802
+ attr("body", opts.body && resolveStateKey(opts.body, ctx)) +
803
+ attr("deps", deps.join(",")) +
804
+ `></span>`;
805
+ return `<script type="application/json" data-wd-state>${safeScriptJson(seeds)}</script>${marker}`;
806
+ }
807
+
673
808
  /**
674
809
  * @param {string} line
675
810
  * @param {Ctx} ctx
@@ -855,6 +990,9 @@ function handleButton(line, ctx) {
855
990
  if (!match) throw new Error(`Malformed :button in ${ctx.file}: ${line}`);
856
991
  ctx.comp.assets.runtime = true;
857
992
  const action = parseAction(match[2], ctx);
993
+ if (Array.isArray(action)) {
994
+ return `<button type="button" data-wd-actions="${escapeHtml(JSON.stringify(action))}">${escapeHtml(match[1])}</button>`;
995
+ }
858
996
  const valueAttr = action.value === undefined ? "" : ` data-wd-value="${escapeHtml(JSON.stringify(action.value))}"`;
859
997
  return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
860
998
  }
@@ -872,6 +1010,17 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
872
1010
  const segs = match[1].split(".");
873
1011
  const head = segs[0];
874
1012
 
1013
+ // Per-row meta vars in :if — only valid inside a loop.
1014
+ if (LOOP_META[head]) {
1015
+ if (!ctx.loopMeta) throw new Error(`":if ${match[1]}" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Use it inside a loop body.`);
1016
+ if (ctx.loopItem) {
1017
+ const truthy = compileBody(truthyLines, ctx).trim();
1018
+ const falsy = compileBody(falsyLines, ctx).trim();
1019
+ return `<span data-wd-each-if data-wd-meta="${LOOP_META[head]}"><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
1020
+ }
1021
+ // static: the meta boolean is in scope — fall through to the static branch below.
1022
+ }
1023
+
875
1024
  const staticValue = lookupVar(ctx.scope, head);
876
1025
  if (staticValue.found) {
877
1026
  const active = Boolean(getPath(staticValue.value, segs.slice(1)));
@@ -900,24 +1049,116 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
900
1049
  return `<div data-wd-if="${key}"${pathAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
901
1050
  }
902
1051
 
1052
+ // The fixed corrective suggestion shown for any malformed @loop header.
1053
+ const LOOP_USAGE = "Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N]";
1054
+
1055
+ // Reserved per-row meta variables, mapped to their runtime marker token.
1056
+ /** @type {Record<string, string>} */
1057
+ const LOOP_META = { $index: "index", $number: "number", $first: "first", $last: "last", $count: "count" };
1058
+
1059
+ /**
1060
+ * Parse the optional clause tail of a `@loop` header in FIXED order:
1061
+ * `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N]`.
1062
+ * @param {string} tail Everything after `@loop src into item`.
1063
+ * @param {string} itemName
1064
+ * @param {Ctx} ctx
1065
+ * @returns {{ where: string|null, sort: {key:string,dir:string}|null, reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean }}
1066
+ */
1067
+ function parseLoopClauses(tail, itemName, ctx) {
1068
+ // Peel clauses off the END in reverse fixed-order (limit, offset, reverse,
1069
+ // sort by). Parsing from the tail avoids mistaking a state operand named
1070
+ // `limit`/`offset` inside the `where` predicate for a clause keyword. Whatever
1071
+ // remains must be `where …` (or empty); a clause keyword surviving in the
1072
+ // remainder means the author wrote the clauses out of order.
1073
+ let s = tail.trim();
1074
+ /** @type {{key:string,dir:string}|null} */ let sort = null;
1075
+ let reverse = false;
1076
+ /** @type {NumArg|null} */ let offset = null;
1077
+ /** @type {NumArg|null} */ let limit = null;
1078
+ let refsState = false;
1079
+ /** @returns {never} */
1080
+ const bad = () => { throw new Error(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`); };
1081
+
1082
+ let m = s.match(/(^|\s)limit\s+(\d+|[A-Za-z_$][\w$]*)$/);
1083
+ if (m) { limit = parseNumArg(m[2], ctx); refsState = refsState || limit.kind === "key"; s = s.slice(0, s.length - m[0].length + m[1].length).trim(); }
1084
+
1085
+ m = s.match(/(^|\s)offset\s+(\d+|[A-Za-z_$][\w$]*)$/);
1086
+ if (m) { offset = parseNumArg(m[2], ctx); refsState = refsState || offset.kind === "key"; s = s.slice(0, s.length - m[0].length + m[1].length).trim(); }
1087
+
1088
+ m = s.match(/(^|\s)reverse$/);
1089
+ if (m) { reverse = true; s = s.slice(0, s.length - m[0].length + m[1].length).trim(); }
1090
+
1091
+ m = s.match(/(^|\s)sort\s+by\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(?:\s+(asc|desc))?$/);
1092
+ if (m) {
1093
+ const segs = m[2].split(".");
1094
+ if (segs[0] !== itemName) throw new Error(`@loop sort key "${m[2]}" must start with the loop item "${itemName}" in ${ctx.file}. ${LOOP_USAGE}`);
1095
+ const rest = segs.slice(1);
1096
+ if (rest.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
1097
+ throw new Error(`Sort key "${m[2]}" is not allowed in @loop (${ctx.file})`);
1098
+ }
1099
+ sort = { key: rest.join("."), dir: m[3] || "asc" };
1100
+ s = s.slice(0, s.length - m[0].length + m[1].length).trim();
1101
+ }
1102
+
1103
+ /** @type {string|null} */ let where = null;
1104
+ if (s.length) {
1105
+ m = s.match(/^where\s+(.+)$/);
1106
+ if (!m) return bad(); // leftover non-where text → clauses written out of order
1107
+ where = m[1].trim();
1108
+ // `sort by`/`reverse` are never valid where operands; their presence in the
1109
+ // predicate means a clause was written before `where` finished (wrong order).
1110
+ if (/(^|\s)(sort\s+by\s|reverse(\s|$))/.test(where)) bad();
1111
+ }
1112
+ return { where, sort, reverse, offset, limit, refsState };
1113
+ }
1114
+
1115
+ /**
1116
+ * A loop offset/limit argument: a non-negative integer literal or a state key.
1117
+ * @typedef {{ kind: "literal", value: number } | { kind: "key", value: string }} NumArg
1118
+ */
1119
+
1120
+ /**
1121
+ * @param {string} tok
1122
+ * @param {Ctx} ctx
1123
+ * @returns {NumArg}
1124
+ */
1125
+ function parseNumArg(tok, ctx) {
1126
+ if (/^\d+$/.test(tok)) return { kind: "literal", value: Number(tok) };
1127
+ if (/^[A-Za-z_$][\w$]*$/.test(tok)) {
1128
+ const key = resolveStateKey(tok, ctx);
1129
+ if (!key) throw new Error(`@loop offset/limit "${tok}" in ${ctx.file} is neither a non-negative integer nor a declared :state. ${LOOP_USAGE}`);
1130
+ return { kind: "key", value: key };
1131
+ }
1132
+ throw new Error(`@loop offset/limit "${tok}" in ${ctx.file} is invalid. ${LOOP_USAGE}`);
1133
+ }
1134
+
1135
+ /** @param {NumArg} arg @returns {string} */
1136
+ function numArgAttr(arg) {
1137
+ return arg.kind === "literal" ? String(arg.value) : `key:${arg.value}`;
1138
+ }
1139
+
903
1140
  /**
904
1141
  * @param {string} line
905
1142
  * @param {string[]} bodyLines
1143
+ * @param {string[] | null} emptyLines
906
1144
  * @param {Ctx} ctx
907
1145
  * @returns {string}
908
1146
  */
909
- function handleLoop(line, bodyLines, ctx) {
910
- const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(?:\s+where\s+(.+?))?\s*$/);
911
- if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. Use: @loop <things> into <thing> [where <predicate>]`);
1147
+ function handleLoop(line, bodyLines, emptyLines, ctx) {
1148
+ const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
1149
+ if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. ${LOOP_USAGE}`);
912
1150
  const source = stripQuotes(match[1].trim());
913
1151
  const itemName = match[2];
914
- const where = match[3] ? compilePredicate(match[3].trim(), itemName, ctx) : null;
1152
+ const clauses = match[3] ? parseLoopClauses(match[3], itemName, ctx) : { where: null, sort: null, reverse: false, offset: null, limit: null, refsState: false };
1153
+ const where = clauses.where ? compilePredicate(clauses.where, itemName, ctx) : null;
1154
+ /** @type {LoopOpts} */
1155
+ const opts = { where, sort: clauses.sort, reverse: clauses.reverse, offset: clauses.offset, limit: clauses.limit, empty: emptyLines, clauseRefsState: clauses.refsState };
915
1156
 
916
1157
  if (source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.endsWith(".json")) {
917
1158
  const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
918
1159
  const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
919
1160
  if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
920
- return loopOverData(rows, itemName, bodyLines, ctx, where);
1161
+ return loopOverData(rows, itemName, bodyLines, ctx, opts);
921
1162
  }
922
1163
 
923
1164
  const resolved = lookupPath(source, ctx);
@@ -925,12 +1166,20 @@ function handleLoop(line, bodyLines, ctx) {
925
1166
  if (!Array.isArray(resolved.value)) {
926
1167
  throw new Error(`@loop ${source} in ${ctx.file} found an in-scope value, but it is not a list`);
927
1168
  }
928
- return loopOverData(resolved.value, itemName, bodyLines, ctx, where);
1169
+ return loopOverData(resolved.value, itemName, bodyLines, ctx, opts);
929
1170
  }
930
1171
 
931
- if (/^[A-Za-z_$][\w$]*$/.test(source)) {
932
- const key = resolveStateKey(source, ctx);
933
- if (key) return reactiveLoop(key, itemName, bodyLines, ctx, where ? { where } : null);
1172
+ // A bare name OR a dotted path resolving to declared :state (e.g. team.members).
1173
+ const segs = source.split(".");
1174
+ if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(source)) {
1175
+ if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
1176
+ throw new Error(`@loop source "${source}" is not allowed in ${ctx.file}`);
1177
+ }
1178
+ const key = resolveStateKey(segs[0], ctx);
1179
+ if (key) {
1180
+ const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
1181
+ return reactiveLoop(fullKey, itemName, bodyLines, ctx, opts);
1182
+ }
934
1183
  }
935
1184
 
936
1185
  throw new Error(
@@ -948,18 +1197,59 @@ function handleLoop(line, bodyLines, ctx) {
948
1197
  * @property {boolean} refsState Whether the predicate reads declared state.
949
1198
  */
950
1199
 
1200
+ /**
1201
+ * Loop clause configuration shared by the static and reactive paths.
1202
+ * @typedef {object} LoopOpts
1203
+ * @property {Predicate | null} where
1204
+ * @property {{ key: string, dir: string } | null} sort
1205
+ * @property {boolean} reverse
1206
+ * @property {NumArg | null} offset
1207
+ * @property {NumArg | null} limit
1208
+ * @property {string[] | null} empty Empty-branch body lines, if any.
1209
+ * @property {boolean} clauseRefsState Whether offset/limit reference state.
1210
+ */
1211
+
1212
+ /**
1213
+ * Build-time pipeline over already-resolved rows: filter → sort → reverse →
1214
+ * offset → limit. Used when source + every clause arg are static.
1215
+ * @param {unknown[]} rows
1216
+ * @param {Predicate | null} where
1217
+ * @param {LoopOpts} opts
1218
+ * @param {Ctx} ctx
1219
+ * @returns {unknown[]}
1220
+ */
1221
+ function pipelineRows(rows, where, opts, ctx) {
1222
+ let list = where ? rows.filter((row) => evalPredicate(where.body, row, ctx)) : rows.slice();
1223
+ if (opts.sort) {
1224
+ const k = opts.sort.key;
1225
+ const dir = opts.sort.dir === "desc" ? -1 : 1;
1226
+ list = list.map((value, index) => ({ value, index })).sort((a, b) => {
1227
+ const av = getPath(a.value, k ? k.split(".") : []);
1228
+ const bv = getPath(b.value, k ? k.split(".") : []);
1229
+ let c = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
1230
+ return (c || a.index - b.index) * dir;
1231
+ }).map((w) => w.value);
1232
+ }
1233
+ if (opts.reverse) list.reverse();
1234
+ const off = opts.offset && opts.offset.kind === "literal" ? opts.offset.value : 0;
1235
+ if (off) list = list.slice(off);
1236
+ if (opts.limit && opts.limit.kind === "literal") list = list.slice(0, opts.limit.value);
1237
+ return list;
1238
+ }
1239
+
951
1240
  /**
952
1241
  * @param {unknown[]} rows
953
1242
  * @param {string} itemName
954
1243
  * @param {string[]} bodyLines
955
1244
  * @param {Ctx} ctx
956
- * @param {Predicate | null} where
1245
+ * @param {LoopOpts} opts
957
1246
  * @returns {string}
958
1247
  */
959
- function loopOverData(rows, itemName, bodyLines, ctx, where) {
960
- if (!where) return staticUnroll(rows, itemName, bodyLines, ctx);
961
- if (!where.refsState) return staticUnroll(rows.filter((row) => evalPredicate(where.body, row, ctx)), itemName, bodyLines, ctx);
962
- return reactiveLoop(null, itemName, bodyLines, ctx, { where, data: rows });
1248
+ function loopOverData(rows, itemName, bodyLines, ctx, opts) {
1249
+ // Reactive when the where reads state OR an offset/limit references a state key.
1250
+ const reactive = (opts.where && opts.where.refsState) || opts.clauseRefsState;
1251
+ if (reactive) return reactiveLoop(null, itemName, bodyLines, ctx, { ...opts, data: rows });
1252
+ return staticUnroll(pipelineRows(rows, opts.where, opts, ctx), itemName, bodyLines, ctx, opts.empty);
963
1253
  }
964
1254
 
965
1255
  // Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
@@ -1056,30 +1346,36 @@ function evalPredicate(body, item, ctx) {
1056
1346
  * @param {string} itemName
1057
1347
  * @param {string[]} bodyLines
1058
1348
  * @param {Ctx} ctx
1349
+ * @param {string[] | null} [empty] Empty-branch body, rendered when 0 rows.
1059
1350
  * @returns {string}
1060
1351
  */
1061
- function staticUnroll(rows, itemName, bodyLines, ctx) {
1352
+ function staticUnroll(rows, itemName, bodyLines, ctx, empty = null) {
1353
+ if (rows.length === 0 && empty) return compileBody(empty, { ...ctx, loopMeta: true });
1062
1354
  const out = [];
1063
- for (const row of rows) {
1064
- const rowCtx = { ...ctx, scope: createScope(ctx.scope, { [itemName]: row }) };
1355
+ const count = rows.length;
1356
+ for (let i = 0; i < count; i++) {
1357
+ const meta = { $index: i, $number: i + 1, $first: i === 0, $last: i === count - 1, $count: count };
1358
+ const rowCtx = { ...ctx, loopMeta: true, scope: createScope(ctx.scope, { [itemName]: rows[i], ...meta }) };
1065
1359
  out.push(compileBody(bodyLines, rowCtx));
1066
1360
  }
1067
1361
  return out.join("\n");
1068
1362
  }
1069
1363
 
1070
1364
  /**
1071
- * Emit a reactive loop region (template + initial rows) for the runtime.
1072
- * @param {string | null} key State key of the list, or null for baked data.
1365
+ * Emit a reactive loop region (template + initial rows + clause config) for the
1366
+ * runtime. `key` may be a dotted path (e.g. `team.members`); the runtime reads
1367
+ * it via getPath. `opts` carries where/sort/reverse/offset/limit/empty.
1368
+ * @param {string | null} key State key/path of the list, or null for baked data.
1073
1369
  * @param {string} itemName
1074
1370
  * @param {string[]} bodyLines
1075
1371
  * @param {Ctx} ctx
1076
- * @param {{ where?: Predicate, data?: unknown[] } | null} [opts]
1372
+ * @param {Partial<LoopOpts> & { data?: unknown[] } | null} [opts]
1077
1373
  * @returns {string}
1078
1374
  */
1079
1375
  function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
1080
1376
  ctx.comp.assets.runtime = true;
1081
1377
  /** @type {Ctx} */
1082
- const templateCtx = { ...ctx, loopItem: itemName, loopKey: key ?? undefined, scope: createScope(ctx.scope) };
1378
+ const templateCtx = { ...ctx, loopItem: itemName, loopKey: key ?? undefined, loopMeta: true, scope: createScope(ctx.scope) };
1083
1379
  const templateHtml = compileBody(bodyLines, templateCtx).trim();
1084
1380
 
1085
1381
  let wrapperTag = "div";
@@ -1093,23 +1389,44 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
1093
1389
  }
1094
1390
 
1095
1391
  const where = opts?.where || null;
1392
+ const sort = opts?.sort || null;
1393
+ const reverse = opts?.reverse || false;
1394
+ const offset = opts?.offset || null;
1395
+ const limit = opts?.limit || null;
1396
+ const emptyLines = opts?.empty || null;
1096
1397
  const baked = opts?.data || null; // rows for a static source filtered by state
1097
- const stateRows = key ? ctx.comp.state.get(key) : null;
1398
+ // Resolve the (possibly dotted) source for the initial paint.
1399
+ const segs = key ? key.split(".") : [];
1400
+ const stateRows = key ? getPath(ctx.comp.state.get(segs[0]), segs.slice(1)) : null;
1098
1401
  /** @type {unknown[]} */
1099
1402
  const allRows = key ? (Array.isArray(stateRows) ? stateRows : []) : (baked || []);
1100
- const rows = where ? allRows.filter((/** @type {unknown} */ row) => evalPredicate(where.body, row, ctx)) : allRows;
1403
+ // Initial paint runs the same pipeline the runtime will, reading state-key
1404
+ // offset/limit from their current declared values.
1405
+ /** @param {NumArg|null} a */
1406
+ const num = (a) => a ? (a.kind === "literal" ? a.value : Number(ctx.comp.state.get(a.value) ?? 0)) : null;
1407
+ const rows = pipelineRows(allRows, where, { where, sort, reverse, offset: offset && offset.kind === "key" ? { kind: "literal", value: num(offset) || 0 } : offset, limit: limit && limit.kind === "key" ? { kind: "literal", value: num(limit) ?? allRows.length } : limit, empty: null, clauseRefsState: false }, ctx);
1101
1408
  /** @type {Map<string, number>} */
1102
1409
  const counts = new Map();
1410
+ const count = rows.length;
1103
1411
  const initial = rows
1104
- .map((/** @type {unknown} */ item) => {
1412
+ .map((/** @type {unknown} */ item, i) => {
1105
1413
  const itemKey = loopKeyOf(item, counts);
1106
- return fillTemplateString(withLoopKey(itemTemplate, itemKey), item);
1414
+ const meta = { index: i, number: i + 1, first: i === 0, last: i === count - 1, count };
1415
+ return fillTemplateString(withLoopKey(itemTemplate, itemKey), item, meta);
1107
1416
  })
1108
1417
  .join("");
1109
1418
 
1110
- const whereAttr = where ? ` data-wd-loop-where="${escapeHtml(where.body)}"` : "";
1111
- const dataAttr = baked ? ` data-wd-loop-data="${escapeHtml(JSON.stringify(baked))}"` : "";
1112
- return `<div data-wd-loop="${key || ""}"${whereAttr}${dataAttr}><template data-wd-loop-template>${itemTemplate}</template><${wrapperTag} data-wd-loop-out>${initial}</${wrapperTag}></div>`;
1419
+ const emptyTemplate = emptyLines ? `<template data-wd-loop-empty>${compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()}</template>` : "";
1420
+ const initialOut = count === 0 && emptyLines ? compileBody(emptyLines, { ...ctx, loopMeta: true }).trim() : initial;
1421
+
1422
+ const attrs =
1423
+ (where ? ` data-wd-loop-where="${escapeHtml(where.body)}"` : "") +
1424
+ (sort ? ` data-wd-loop-sort="${escapeHtml(sort.key)}" data-wd-loop-sort-dir="${sort.dir}"` : "") +
1425
+ (reverse ? ` data-wd-loop-reverse` : "") +
1426
+ (offset ? ` data-wd-loop-offset="${numArgAttr(offset)}"` : "") +
1427
+ (limit ? ` data-wd-loop-limit="${numArgAttr(limit)}"` : "") +
1428
+ (baked ? ` data-wd-loop-data="${escapeHtml(JSON.stringify(baked))}"` : "");
1429
+ return `<div data-wd-loop="${escapeHtml(key || "")}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out>${initialOut}</${wrapperTag}></div>`;
1113
1430
  }
1114
1431
 
1115
1432
  /**
@@ -1124,12 +1441,26 @@ function withLoopKey(template, itemKey) {
1124
1441
  /**
1125
1442
  * @param {string} template
1126
1443
  * @param {unknown} item
1444
+ * @param {Record<string, unknown>} [meta] Per-row meta values for the initial paint.
1127
1445
  * @returns {string}
1128
1446
  */
1129
- function fillTemplateString(template, item) {
1447
+ function fillTemplateString(template, item, meta = {}) {
1130
1448
  // Resolve per-item :if regions first (recursively, so nested conditionals are
1131
- // pre-rendered for the initial paint), then fill the plain text bindings.
1132
- return fillEachText(fillEachIfRegions(template, item), item);
1449
+ // pre-rendered for the initial paint), then fill text binds and meta markers.
1450
+ return fillEachMeta(fillEachText(fillEachIfRegions(template, item, meta), item), meta);
1451
+ }
1452
+
1453
+ /**
1454
+ * Fill `<span data-wd-each-meta="…">` markers with their per-row value.
1455
+ * @param {string} str
1456
+ * @param {Record<string, unknown>} meta
1457
+ * @returns {string}
1458
+ */
1459
+ function fillEachMeta(str, meta) {
1460
+ return str.replace(/<span data-wd-each-meta="([a-z]+)"><\/span>/g, (_, name) => {
1461
+ const value = name in meta ? meta[name] : "";
1462
+ return `<span data-wd-each-meta="${name}">${escapeHtml(value ?? "")}</span>`;
1463
+ });
1133
1464
  }
1134
1465
 
1135
1466
  // Walk the string resolving only the OUTERMOST data-wd-each-if regions; each
@@ -1138,9 +1469,10 @@ function fillTemplateString(template, item) {
1138
1469
  /**
1139
1470
  * @param {string} str
1140
1471
  * @param {unknown} item
1472
+ * @param {Record<string, unknown>} [meta]
1141
1473
  * @returns {string}
1142
1474
  */
1143
- function fillEachIfRegions(str, item) {
1475
+ function fillEachIfRegions(str, item, meta = {}) {
1144
1476
  const marker = '<span data-wd-each-if ';
1145
1477
  let result = "";
1146
1478
  let i = 0;
@@ -1149,7 +1481,7 @@ function fillEachIfRegions(str, item) {
1149
1481
  if (start === -1) return result + str.slice(i);
1150
1482
  result += str.slice(i, start);
1151
1483
  const end = matchElement(str, start, "span");
1152
- result += fillOneEachIf(str.slice(start, end), item);
1484
+ result += fillOneEachIf(str.slice(start, end), item, meta);
1153
1485
  i = end;
1154
1486
  }
1155
1487
  }
@@ -1157,9 +1489,11 @@ function fillEachIfRegions(str, item) {
1157
1489
  /**
1158
1490
  * @param {string} region
1159
1491
  * @param {unknown} item
1492
+ * @param {Record<string, unknown>} meta
1160
1493
  * @returns {string}
1161
1494
  */
1162
- function fillOneEachIf(region, item) {
1495
+ function fillOneEachIf(region, item, meta) {
1496
+ const metaMatch = region.match(/^<span data-wd-each-if data-wd-meta="([a-z]+)">/);
1163
1497
  const path = (region.match(/^<span data-wd-each-if data-wd-path="([^"]*)">/) || ["", ""])[1];
1164
1498
  const trueStart = region.indexOf("<template data-wd-if-true>");
1165
1499
  const trueEnd = matchElement(region, trueStart, "template");
@@ -1169,9 +1503,10 @@ function fillOneEachIf(region, item) {
1169
1503
  const close = "</template>".length;
1170
1504
  const truthy = region.slice(trueStart + open, trueEnd - close);
1171
1505
  const falsy = region.slice(falseStart + "<template data-wd-if-false>".length, falseEnd - close);
1172
- const branch = getPath(item, path ? path.split(".") : []) ? truthy : falsy;
1506
+ const test = metaMatch ? Boolean(meta[metaMatch[1]]) : Boolean(getPath(item, path ? path.split(".") : []));
1507
+ const branch = test ? truthy : falsy;
1173
1508
  const head = region.slice(0, falseEnd);
1174
- return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item)}</span></span>`;
1509
+ return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item, meta)}</span></span>`;
1175
1510
  }
1176
1511
 
1177
1512
  /**
@@ -1298,6 +1633,13 @@ function resolveBindingHtml(expr, ctx) {
1298
1633
  const segs = expr.split(".");
1299
1634
  const head = segs[0];
1300
1635
 
1636
+ // Per-row meta vars ($index/$number/$first/$last/$count) — only inside a loop.
1637
+ if (LOOP_META[head]) {
1638
+ if (!ctx.loopMeta) throw new Error(`"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value.`);
1639
+ if (ctx.loopItem) return `<span data-wd-each-meta="${LOOP_META[head]}"></span>`; // reactive: filled per row
1640
+ // static: the value is in scope (injected by staticUnroll); fall through.
1641
+ }
1642
+
1301
1643
  if (ctx.loopItem && head === ctx.loopItem) {
1302
1644
  const rest = segs.slice(1).join(".");
1303
1645
  return `<span data-wd-each${rest ? ` data-wd-path="${escapeHtml(rest)}"` : ""}></span>`;
@@ -1384,6 +1726,22 @@ function getPath(value, segments) {
1384
1726
  return current;
1385
1727
  }
1386
1728
 
1729
+ /**
1730
+ * Validate a dotted path shape, rejecting prototype-pollution segments at every
1731
+ * level. Shared by actions/loops/fetch. Throws with a corrective `Use:` hint.
1732
+ * @param {string} path Dotted path, e.g. `cart.items`.
1733
+ * @param {Ctx} ctx
1734
+ * @param {string} use Corrective suggestion for the error message.
1735
+ * @returns {string[]} The validated segments.
1736
+ */
1737
+ function validatePath(path, ctx, use) {
1738
+ const segs = path.split(".");
1739
+ if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
1740
+ throw new Error(`Path segment in "${path}" is not allowed in ${ctx.file}. Use: ${use}`);
1741
+ }
1742
+ return segs;
1743
+ }
1744
+
1387
1745
  /**
1388
1746
  * Resolve a bare state name to its fully-qualified key, walking section scopes.
1389
1747
  * @param {string} name
@@ -1405,35 +1763,56 @@ function resolveStateKey(name, ctx) {
1405
1763
  /**
1406
1764
  * A parsed `:button` action.
1407
1765
  * @typedef {object} Action
1408
- * @property {string} op Runtime operation (inc/dec/add/append/set/remove/append-row).
1409
- * @property {string} target State key the action mutates.
1766
+ * @property {string} op Runtime operation (inc/dec/add/sub/append/prepend/set/toggle/member-toggle/remove/remove-value/clear/merge/delete/reset/append-row).
1767
+ * @property {string} target State key (possibly dotted) the action mutates.
1410
1768
  * @property {unknown} [value] Literal value for value-carrying ops.
1411
1769
  */
1412
1770
 
1771
+ const ACTION_USE =
1772
+ 'Use: name++, name--, n += k, n -= k, name = v, flag toggle, list append/prepend v, list toggle v, list remove v, x clear, obj merge other, obj delete key, name reset — chain with ";".';
1773
+
1413
1774
  /**
1414
- * Parse a `:button` action expression into a validated `{ op, target, value }`.
1775
+ * Parse a `:button` action expression into a validated `{ op, target, value }`,
1776
+ * or — when `;`-separated — an ordered array of them applied in sequence.
1415
1777
  * @param {string} raw
1416
1778
  * @param {Ctx} ctx
1417
- * @returns {Action}
1779
+ * @returns {Action | Action[]}
1418
1780
  */
1419
1781
  function parseAction(raw, ctx) {
1420
- const expression = raw.trim();
1782
+ const parts = raw.split(";").map((p) => p.trim()).filter(Boolean);
1783
+ if (parts.length > 1) return parts.map((part) => parseSingleAction(part, raw, ctx));
1784
+ return parseSingleAction(raw.trim(), raw, ctx);
1785
+ }
1786
+
1787
+ /**
1788
+ * Parse one (non-`;`) action expression into a validated `{ op, target, value }`.
1789
+ * Targets may be dotted paths (`cart.count`); a bare name is a 1-segment path.
1790
+ * @param {string} expression
1791
+ * @param {string} raw The full original action source (for error context).
1792
+ * @param {Ctx} ctx
1793
+ * @returns {Action}
1794
+ */
1795
+ function parseSingleAction(expression, raw, ctx) {
1796
+ const PATH = "[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*";
1421
1797
  /** @param {string} name @returns {string} */
1422
1798
  const resolveTarget = (name) => {
1423
- const key = resolveStateKey(name, ctx);
1799
+ const segs = validatePath(name, ctx, ACTION_USE);
1800
+ const key = resolveStateKey(segs[0], ctx);
1424
1801
  if (!key) {
1425
- throw new Error(`Button action targets unknown state "${name}" in ${ctx.file}. Declare it first with :state ${name} = ...`);
1802
+ throw new Error(`Button action targets unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`);
1426
1803
  }
1427
- return key;
1804
+ return [key, ...segs.slice(1)].join(".");
1428
1805
  };
1429
1806
 
1430
- const increment = expression.match(/^([A-Za-z_$][\w$]*)\+\+$/);
1807
+ const increment = expression.match(new RegExp(`^(${PATH})\\+\\+$`));
1431
1808
  if (increment) return { op: "inc", target: resolveTarget(increment[1]) };
1432
- const decrement = expression.match(/^([A-Za-z_$][\w$]*)--$/);
1809
+ const decrement = expression.match(new RegExp(`^(${PATH})--$`));
1433
1810
  if (decrement) return { op: "dec", target: resolveTarget(decrement[1]) };
1811
+ const sub = expression.match(new RegExp(`^(${PATH})\\s*-=\\s*(.+)$`));
1812
+ if (sub) return { op: "sub", target: resolveTarget(sub[1]), value: parseActionLiteral(sub[2]) };
1434
1813
  // Per-row: carry the current loop item into another list — `cart += product`.
1435
1814
  // Only inside a reactive @loop; the runtime resolves the row from the DOM.
1436
- const add = expression.match(/^([A-Za-z_$][\w$]*)\s*\+=\s*(.+)$/);
1815
+ const add = expression.match(new RegExp(`^(${PATH})\\s*\\+=\\s*(.+)$`));
1437
1816
  if (add) {
1438
1817
  const rhs = add[2].trim();
1439
1818
  if (ctx.loopItem && rhs === ctx.loopItem) {
@@ -1449,23 +1828,66 @@ function parseAction(raw, ctx) {
1449
1828
  if (typeof value === "number") return { op: "add", target, value };
1450
1829
  throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. += with non-number values requires a list state target — declare it "${add[1]} = []".`);
1451
1830
  }
1831
+ // `flag toggle` (no operand) → boolean flip; `list toggle v` → member-toggle.
1832
+ const toggle = expression.match(new RegExp(`^(${PATH})\\s+toggle(?:\\s+(.+))?$`));
1833
+ if (toggle) {
1834
+ const target = resolveTarget(toggle[1]);
1835
+ if (toggle[2] === undefined) return { op: "toggle", target };
1836
+ return { op: "member-toggle", target, value: parseActionLiteral(toggle[2]) };
1837
+ }
1838
+ const prepend = expression.match(new RegExp(`^(${PATH})\\s+(?:append|prepend)\\s+(.+)$`));
1839
+ if (prepend) {
1840
+ const op = expression.includes(" prepend ") ? "prepend" : "append";
1841
+ return { op, target: resolveTarget(prepend[1]), value: parseActionLiteral(prepend[2]) };
1842
+ }
1452
1843
  // Per-row: remove the current row from the list being looped — `todos remove todo`.
1453
- const remove = expression.match(/^([A-Za-z_$][\w$]*)\s+remove\s+([A-Za-z_$][\w$]*)$/);
1844
+ // Disambiguation: operand IS the loop item name → `remove` (current row);
1845
+ // otherwise the operand is a value → `remove-value`.
1846
+ const remove = expression.match(new RegExp(`^(${PATH})\\s+remove\\s+(.+)$`));
1454
1847
  if (remove) {
1455
- if (!ctx.loopItem) {
1456
- throw new Error(`Button action "${raw}" is only valid inside a reactive @loop in ${ctx.file}. Use: :button "Remove" -> list remove item`);
1457
- }
1458
- if (remove[2] !== ctx.loopItem) {
1459
- throw new Error(`Button action "${raw}" must remove the loop item "${ctx.loopItem}" in ${ctx.file}, not "${remove[2]}".`);
1460
- }
1461
- if (!ctx.loopKey || resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
1462
- throw new Error(`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`);
1848
+ const operand = remove[2].trim();
1849
+ if (ctx.loopItem && operand === ctx.loopItem) {
1850
+ if (!ctx.loopKey || resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
1851
+ throw new Error(`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`);
1852
+ }
1853
+ return { op: "remove", target: ctx.loopKey };
1463
1854
  }
1464
- return { op: "remove", target: ctx.loopKey };
1855
+ return { op: "remove-value", target: resolveTarget(remove[1]), value: parseActionLiteral(operand) };
1465
1856
  }
1466
- const assign = expression.match(/^([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
1857
+ const clear = expression.match(new RegExp(`^(${PATH})\\s+clear$`));
1858
+ if (clear) return { op: "clear", target: resolveTarget(clear[1]) };
1859
+ const merge = expression.match(new RegExp(`^(${PATH})\\s+merge\\s+(.+)$`));
1860
+ if (merge) return { op: "merge", target: resolveTarget(merge[1]), value: parseMergeOperand(merge[2], ctx) };
1861
+ const del = expression.match(new RegExp(`^(${PATH})\\s+delete\\s+(.+)$`));
1862
+ if (del) return { op: "delete", target: resolveTarget(del[1]), value: parseActionLiteral(del[2]) };
1863
+ const reset = expression.match(new RegExp(`^(${PATH})\\s+reset$`));
1864
+ if (reset) return { op: "reset", target: resolveTarget(reset[1]) };
1865
+ // `name refetch` re-invokes the matching :fetch node. The target is the fetch
1866
+ // key (bare name); the runtime finds the [data-wd-fetch-key] node and re-runs.
1867
+ const refetch = expression.match(new RegExp(`^(${PATH})\\s+refetch$`));
1868
+ if (refetch) return { op: "refetch", target: resolveTarget(refetch[1]) };
1869
+ const assign = expression.match(new RegExp(`^(${PATH})\\s*=\\s*(.+)$`));
1467
1870
  if (assign) return { op: "set", target: resolveTarget(assign[1]), value: parseActionLiteral(assign[2]) };
1468
- throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. Supported actions: count++, count--, count += 1, items += "value", name = "value", list remove item (in a loop), cart += item (in a loop).`);
1871
+ throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. ${ACTION_USE}`);
1872
+ }
1873
+
1874
+ /**
1875
+ * `merge` operand: either an inline JSON object literal, or a state/store key
1876
+ * name (emitted as a string the runtime resolves via getPath).
1877
+ * @param {string} raw
1878
+ * @param {Ctx} ctx
1879
+ * @returns {unknown}
1880
+ */
1881
+ function parseMergeOperand(raw, ctx) {
1882
+ const value = raw.trim();
1883
+ if (/^[A-Za-z_$][\w$]*$/.test(value)) {
1884
+ const key = resolveStateKey(value, ctx);
1885
+ if (!key) throw new Error(`Button action merge targets unknown state "${value}" in ${ctx.file}. Declare it first with :state ${value} = ...`);
1886
+ return key;
1887
+ }
1888
+ const parsed = parseActionLiteral(value);
1889
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
1890
+ throw new Error(`Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object).`);
1469
1891
  }
1470
1892
 
1471
1893
  /**
@@ -1483,10 +1905,10 @@ function parseActionLiteral(raw) {
1483
1905
  try {
1484
1906
  return JSON.parse(value);
1485
1907
  } catch {
1486
- throw new Error(`Unsupported action literal "${raw}". Use a quoted string, number, boolean, null, or valid JSON.`);
1908
+ throw new Error(`Unsupported action literal "${raw}". Use: a quoted string, number, boolean, null, or valid JSON.`);
1487
1909
  }
1488
1910
  }
1489
- throw new Error(`Unsupported action literal "${raw}". Use a quoted string, number, boolean, null, or valid JSON.`);
1911
+ throw new Error(`Unsupported action literal "${raw}". Use: a quoted string, number, boolean, null, or valid JSON.`);
1490
1912
  }
1491
1913
 
1492
1914
  // ---------------------------------------------------------------------------