@zvndev/darkmown 0.12.1 → 0.14.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.
@@ -16,6 +16,16 @@ The count is { count }.
16
16
 
17
17
  :button "Increment" -> count++
18
18
 
19
+ `:if` reads JavaScript-style comparisons — `==` `!=` `<` `<=` `>` `>=` `contains` — joined with `and`, `or`, and `not`. This chain reacts to the counter above, no handler wired by hand:
20
+
21
+ :if count >= 3 and count <= 5
22
+ Counter is in the sweet spot (3–5).
23
+ :else if count > 5
24
+ Counter is over the top.
25
+ :else
26
+ Counter is warming up.
27
+ :endif
28
+
19
29
  :state open = false
20
30
 
21
31
  :button "Show details" -> open = true
@@ -39,30 +49,34 @@ The same `@loop` that unrolls static data also drives reactive lists. Items are
39
49
 
40
50
  :button "Add todo" -> todos += {"id": 3, "title": "Live compile", "owner": "Testing"}
41
51
 
42
- ## Conditionals inside a loop, nested
52
+ ## Multi-branch conditionals + reactive classes
53
+
54
+ `:if … :else if … :else … :endif` picks one of several branches per row, and a container class can react to the row too: `.class when <predicate>` toggles a class from the same whitelist `@loop … where` uses. Each card below picks one of three badges *and* tags itself `.is-done` / `.is-blocked`, all reactive:
43
55
 
44
- `:if` works per row, and conditionals nest — the inner branch is decided after the outer one, for every item, and stays reactive. Add a "done" task and watch which badge each row shows:
56
+ <style>
57
+ .task-row { border-left: 4px solid #9991; padding-left: .6rem; }
58
+ .task-row.is-done { border-left-color: #2e9e5b; }
59
+ .task-row.is-blocked { border-left-color: #d2453f; }
60
+ </style>
45
61
 
46
62
  :state tasks = [{"id": 1, "title": "Draft the spec", "done": true, "blocked": false}, {"id": 2, "title": "Wire the runtime", "done": false, "blocked": true}, {"id": 3, "title": "Write the demo", "done": false, "blocked": false}]
47
63
 
48
64
  @loop tasks into task
49
- ::: card .task-row
65
+ ::: card .task-row .is-done when task.done .is-blocked when task.blocked
50
66
  **{ task.title }** —
51
67
  :if task.done
52
68
  done ✓
53
- :else
54
- :if task.blocked
69
+ :else if task.blocked
55
70
  blocked ✗
56
71
  :else
57
72
  in progress …
58
73
  :endif
59
- :endif
60
74
  :::
61
75
  @endloop
62
76
 
63
77
  :button "Add a finished task" -> tasks += {"id": 4, "title": "Ship it", "done": true, "blocked": false}
64
78
 
65
- Each row picks one of three states from two nested conditionals, all in plain Markdown — no component, no ternary, no JavaScript.
79
+ Each row picks one of three states from a single `:else if` chain, and its left border color comes from a reactive `.class when …` — plain Markdown, no component, no ternary, no JavaScript.
66
80
 
67
81
  ## Live filtering — search in pure Markdown
68
82
 
@@ -83,6 +97,15 @@ Each row picks one of three states from two nested conditionals, all in plain Ma
83
97
 
84
98
  The predicate whitelist is compile-time validated — item fields, declared state, numbers, and strings only. An item-only predicate (say `where p.price < 50`) would instead filter at build time and ship zero JavaScript.
85
99
 
100
+ ### …and an effect that watches the search
101
+
102
+ `:effect <state> -> <actions>` runs actions whenever the watched state changes — for side effects past deriving state. This one counts how many times the search box above has changed:
103
+
104
+ :state searches = 0
105
+ :effect q -> searches++
106
+
107
+ The search has changed **{ searches }** time(s). Type in the box above and this updates on its own — no handler wired by hand.
108
+
86
109
  ### …and an editable cart
87
110
 
88
111
  A `:button` inside a loop can act on its own row. **Add to cart** carries the row into another list; **Remove** drops a line — a whole add/remove flow with no JavaScript:
package/src/builder.js CHANGED
@@ -71,6 +71,11 @@ export function buildSite(cwd = process.cwd()) {
71
71
  fs.writeFileSync(path.join(paths.distRoot, "404.html"), notFoundHtml ?? defaultNotFoundHtml());
72
72
 
73
73
  fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
74
+
75
+ // Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
76
+ // sees every emitted route/framework file already on disk.
77
+ emitPageAssets(paths);
78
+
74
79
  return { routes: manifest, distRoot: paths.distRoot };
75
80
  }
76
81
 
@@ -168,6 +173,33 @@ function emitShelfAssets(paths) {
168
173
  }
169
174
  }
170
175
 
176
+ /**
177
+ * Copy page-colocated static assets (images, SVG, fonts, …) from `site/pages`
178
+ * into `dist`, preserving their relative path: `site/pages/logo.svg` →
179
+ * `dist/logo.svg`; `site/pages/blog/cover.png` → `dist/blog/cover.png`. Routes
180
+ * (`.md`/`.wd`) and the compiler's colocated `.skin`/`.js` files are skipped —
181
+ * those have their own emit paths. A backstop guard skips any asset whose output
182
+ * would clobber an already-emitted route/framework file (in practice their
183
+ * extensions differ, so this only fires on a genuine name clash).
184
+ * @param {Paths} paths
185
+ * @returns {void}
186
+ */
187
+ function emitPageAssets(paths) {
188
+ if (!fs.existsSync(paths.routesRoot)) return;
189
+ for (const file of walk(paths.routesRoot)) {
190
+ const ext = path.extname(file).toLowerCase();
191
+ if ([".md", ".wd", ".skin", ".js"].includes(ext)) continue;
192
+ const rel = path.relative(paths.routesRoot, file);
193
+ const out = path.join(paths.distRoot, rel);
194
+ if (fs.existsSync(out)) {
195
+ console.warn(`hint: page asset "${rel}" skipped — a built route already emits /${rel.replaceAll(path.sep, "/")}`);
196
+ continue;
197
+ }
198
+ fs.mkdirSync(path.dirname(out), { recursive: true });
199
+ fs.copyFileSync(file, out);
200
+ }
201
+ }
202
+
171
203
  /**
172
204
  * @param {string} dir
173
205
  * @returns {string[]}
package/src/compiler.js CHANGED
@@ -377,6 +377,11 @@ function compileBody(lines, ctx) {
377
377
  out.push(handleComputed(line, ctx));
378
378
  continue;
379
379
  }
380
+ if (/^:effect\s/.test(line)) {
381
+ flush();
382
+ out.push(handleEffect(line, ctx));
383
+ continue;
384
+ }
380
385
  if (/^:form\s/.test(line)) {
381
386
  flush();
382
387
  const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx.file);
@@ -542,7 +547,13 @@ function scanContainer(lines, start, file) {
542
547
  }
543
548
 
544
549
  /**
545
- * Split an `:if … :else … :endif` block into its two branches.
550
+ * Split an `:if … :else if … :else … :endif` chain into the first condition's
551
+ * truthy body and a falsy body. A bare `:else` works as before. The first
552
+ * `:else if B` is **desugared** into a nested `:if` that lives in the falsy
553
+ * branch: the falsy body becomes `[":if B", …rest…, ":endif"]`, which re-enters
554
+ * `compileBody`/`handleIf` recursively. A whole `:if/:else if/:else` chain
555
+ * therefore compiles to nested `data-wd-if` regions the runtime already drives —
556
+ * identical behavior for static, reactive, and loop-row conditionals.
546
557
  * @param {string[]} lines
547
558
  * @param {number} start Index of the `:if` line.
548
559
  * @param {string} file Source path for errors.
@@ -554,6 +565,11 @@ function scanConditional(lines, start, file) {
554
565
  /** @type {string[]} */
555
566
  const falsy = [];
556
567
  let current = truthy;
568
+ // "truthy": collecting the :if body. "else": a bare :else closed the chain —
569
+ // no further branches allowed. "elseif": the first :else if was desugared into
570
+ // a nested :if inside `falsy`; subsequent depth-0 :else if/:else lines belong to
571
+ // that nested chain and are emitted verbatim for the recursive compile to split.
572
+ let mode = "truthy";
557
573
  let depth = 0;
558
574
  let fence = null;
559
575
  for (let i = start + 1; i < lines.length; i++) {
@@ -571,12 +587,28 @@ function scanConditional(lines, start, file) {
571
587
  }
572
588
  if (/^:if\s/.test(line)) depth++;
573
589
  if (line.trim() === ":endif") {
574
- if (depth === 0) return { truthy, falsy, end: i };
590
+ if (depth === 0) {
591
+ if (mode === "elseif") falsy.push(":endif"); // close the synthesized nested :if
592
+ return { truthy, falsy, end: i };
593
+ }
575
594
  depth--;
576
595
  }
577
- if (line.trim() === ":else" && depth === 0) {
578
- current = falsy;
579
- continue;
596
+ if (depth === 0 && mode !== "elseif") {
597
+ const t = line.trim();
598
+ const elseIf = t.match(/^:else if\s+(.+?)\s*$/);
599
+ if (elseIf) {
600
+ if (mode === "else") throw new Error(`":else if" after ":else" in ${file}. ":else" must be the last branch — order the "else if" conditions before the bare ":else".`);
601
+ falsy.push(`:if ${elseIf[1]}`); // desugar the chain tail into a nested :if
602
+ current = falsy;
603
+ mode = "elseif";
604
+ continue;
605
+ }
606
+ if (t === ":else") {
607
+ if (mode === "else") throw new Error(`Duplicate ":else" in ${file}. A conditional may have only one bare ":else".`);
608
+ current = falsy;
609
+ mode = "else";
610
+ continue;
611
+ }
580
612
  }
581
613
  current.push(line);
582
614
  }
@@ -634,20 +666,44 @@ function parseIncludeArgs(raw, ctx) {
634
666
  * @returns {string}
635
667
  */
636
668
  function handleContainer(header, bodyLines, ctx) {
637
- const tokens = header.split(/\s+/);
669
+ let rest = header.trim();
638
670
  let tag = "section";
639
- /** @type {string[]} */
640
- let extraClass = [];
671
+ /** @type {string[]} Static classes baked into class="". */
672
+ const extraClass = [];
673
+ /** @type {[string, string][]} Reactive loop-item class bindings (data-wd-each-class). */
674
+ const eachClasses = [];
675
+ /** @type {[string, string][]} Global state-driven class bindings (data-wd-class). */
676
+ const stateClasses = [];
641
677
  let id = "";
642
- let nameToken = tokens[0] && !tokens[0].startsWith("#") && !tokens[0].startsWith(".") ? tokens.shift() ?? "section" : "section";
643
- if (nameToken !== "section") {
644
- tag = "div";
645
- extraClass.push(nameToken);
646
- }
647
- for (const token of tokens) {
648
- if (token.startsWith("#")) id = token.slice(1);
649
- else if (token.startsWith(".")) extraClass.push(token.slice(1));
650
- else throw new Error(`Unexpected token "${token}" in container "::: ${header}" in ${ctx.file}`);
678
+ // Leading tag/name token (anything not starting with . or #). "section" keeps
679
+ // the <section> tag; any other name becomes a <div> and also a class.
680
+ const lead = rest.match(/^([^\s.#]\S*)/);
681
+ let nameToken = "section";
682
+ if (lead) { nameToken = lead[1]; rest = rest.slice(lead[0].length).trim(); }
683
+ if (nameToken !== "section") { tag = "div"; extraClass.push(nameToken); }
684
+ while (rest.length) {
685
+ if (rest.startsWith("#")) {
686
+ const m = rest.match(/^#(\S+)/);
687
+ id = (m ? m[1] : "");
688
+ rest = rest.slice((m ? m[0] : rest).length).trim();
689
+ continue;
690
+ }
691
+ const cm = rest.match(/^\.([A-Za-z_][\w-]*)/);
692
+ if (!cm) throw new Error(`Unexpected token "${rest.split(/\s+/)[0]}" in container "::: ${header}" in ${ctx.file}`);
693
+ const cls = cm[1];
694
+ rest = rest.slice(cm[0].length).trim();
695
+ // Optional `when <predicate>` makes the class reactive. The predicate runs to
696
+ // the next ` .`/` #` token or the end of the header.
697
+ const whenMatch = rest.match(/^when\s+(.+?)(?=\s+[.#]|$)/);
698
+ if (whenMatch) {
699
+ rest = rest.slice(whenMatch[0].length).trim();
700
+ const compiled = compileWhen(whenMatch[1].trim(), ctx);
701
+ if (compiled.static) { if (compiled.value) extraClass.push(cls); }
702
+ else if (compiled.item) eachClasses.push([cls, compiled.body]);
703
+ else stateClasses.push([cls, compiled.body]);
704
+ } else {
705
+ extraClass.push(cls);
706
+ }
651
707
  }
652
708
  const explicitId = Boolean(id);
653
709
  if (!id) id = `wd-s${++ctx.comp.sectionCounter}`;
@@ -661,7 +717,10 @@ function handleContainer(header, bodyLines, ctx) {
661
717
  }
662
718
  const idAttr = explicitId ? ` id="${escapeHtml(id)}"` : "";
663
719
  const classAttr = extraClass.length ? ` class="${escapeHtml(extraClass.join(" "))}"` : "";
664
- return `<${tag}${idAttr}${classAttr}>\n${inner}\n</${tag}>`;
720
+ let classBind = "";
721
+ if (eachClasses.length) { ctx.comp.assets.runtime = true; classBind += ` data-wd-each-class="${escapeHtml(JSON.stringify(eachClasses))}"`; }
722
+ if (stateClasses.length) { ctx.comp.assets.runtime = true; classBind += ` data-wd-class="${escapeHtml(JSON.stringify(stateClasses))}"`; }
723
+ return `<${tag}${idAttr}${classAttr}${classBind}>\n${inner}\n</${tag}>`;
665
724
  }
666
725
 
667
726
  /**
@@ -740,7 +799,7 @@ function declareErrorState(key, ctx) {
740
799
  }
741
800
 
742
801
  const FETCH_USE =
743
- 'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key]';
802
+ 'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key] [refresh=url]';
744
803
 
745
804
  /**
746
805
  * Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
@@ -755,14 +814,14 @@ function handleFetch(line, ctx) {
755
814
  const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
756
815
  if (!head) throw new Error(`Malformed :fetch in ${ctx.file}: ${line}. ${FETCH_USE}`);
757
816
  const name = head[1];
758
- const url = stripQuotes(head[2]);
817
+ const url = validateFetchUrl(stripQuotes(head[2]), ctx);
759
818
  /** @type {Record<string, string>} */
760
819
  const opts = {};
761
820
  for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
762
821
  const kv = part.match(/^([A-Za-z]+)=(.+)$/);
763
822
  if (!kv) throw new Error(`Unknown :fetch option "${part}" in ${ctx.file}. ${FETCH_USE}`);
764
823
  const optName = kv[1];
765
- if (!["method", "when", "timeout", "retry", "headers", "body"].includes(optName)) {
824
+ if (!["method", "when", "timeout", "retry", "headers", "body", "refresh"].includes(optName)) {
766
825
  throw new Error(`Unknown :fetch option "${optName}" in ${ctx.file}. ${FETCH_USE}`);
767
826
  }
768
827
  opts[optName] = stripQuotes(kv[2]);
@@ -779,6 +838,14 @@ function handleFetch(line, ctx) {
779
838
  throw new Error(`:fetch ${n} must be a non-negative integer in ${ctx.file}. ${FETCH_USE}`);
780
839
  }
781
840
  }
841
+ if (opts.refresh !== undefined) {
842
+ // Layer 2: a 401 triggers a token-refresh POST to this URL, then one retry.
843
+ // The new token is written back into the `headers=` state, so it is required.
844
+ if (!opts.headers) {
845
+ throw new Error(`:fetch refresh= needs headers= (the state key holding the token to renew) in ${ctx.file}. ${FETCH_USE}`);
846
+ }
847
+ opts.refresh = validateFetchUrl(opts.refresh, ctx, ":fetch refresh");
848
+ }
782
849
 
783
850
  // Extract `{ path }` dependency keys from the URL (validated against poison
784
851
  // segments). The runtime fills them from state on each (re)fetch.
@@ -811,11 +878,40 @@ function handleFetch(line, ctx) {
811
878
  attr("retry", opts.retry) +
812
879
  attr("headers", opts.headers && resolveStateKey(opts.headers, ctx)) +
813
880
  attr("body", opts.body && resolveStateKey(opts.body, ctx)) +
881
+ attr("refresh", opts.refresh) +
814
882
  attr("deps", deps.join(",")) +
815
883
  `></span>`;
816
884
  return `<script type="application/json" data-wd-state>${safeScriptJson(seeds)}</script>${marker}`;
817
885
  }
818
886
 
887
+ /**
888
+ * Validate a `:fetch` (or `refresh=`) URL's scheme at compile time. Mirrors the
889
+ * `:try` href guard: relative paths (`/`, `./`, `../`, bare), an `http(s)://`
890
+ * URL, or a leading `{ state }` interpolation are allowed; a protocol-relative
891
+ * `//host` or any non-http(s) scheme (`file:`, `data:`, `javascript:`, …) is
892
+ * rejected. Interpolated values are percent-encoded by the runtime, so a scheme
893
+ * cannot be injected through state at request time.
894
+ * @param {string} url
895
+ * @param {Ctx} ctx
896
+ * @param {string} [what] Option name for the error message.
897
+ * @returns {string}
898
+ */
899
+ function validateFetchUrl(url, ctx, what = ":fetch") {
900
+ const value = url.trim();
901
+ if (value !== url || value === "" || /[\u0000-\u001F\u007F]/.test(value)) {
902
+ throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Use a relative path (/, ./, ../), an http(s):// URL, or a { state } interpolation.`);
903
+ }
904
+ if (value.startsWith("//")) {
905
+ throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http:// or https:// explicitly.`);
906
+ }
907
+ if (value.startsWith("{")) return value; // interpolation-first; the runtime percent-encodes state values
908
+ const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
909
+ if (scheme && !["http", "https"].includes(scheme[1].toLowerCase())) {
910
+ throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. The "${scheme[1]}:" scheme is not allowed; use http://, https://, or a relative path.`);
911
+ }
912
+ return value;
913
+ }
914
+
819
915
  /**
820
916
  * @param {string} line
821
917
  * @param {Ctx} ctx
@@ -1031,6 +1127,30 @@ function handleButton(line, ctx) {
1031
1127
  return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
1032
1128
  }
1033
1129
 
1130
+ const EFFECT_USE = "Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary).";
1131
+
1132
+ /**
1133
+ * Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
1134
+ * watches: when `<watched>` state changes, it runs `<actions>` (the same `:button`
1135
+ * action vocabulary, `;`-chained). For arbitrary side effects beyond `:computed`
1136
+ * (derive state) and fetch deps (auto-refetch).
1137
+ * @param {string} line
1138
+ * @param {Ctx} ctx
1139
+ * @returns {string}
1140
+ */
1141
+ function handleEffect(line, ctx) {
1142
+ const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
1143
+ if (!match) throw new Error(`Malformed :effect in ${ctx.file}: ${line}. ${EFFECT_USE}`);
1144
+ const segs = validatePath(match[1], ctx, EFFECT_USE);
1145
+ const key = resolveStateKey(segs[0], ctx);
1146
+ if (!key) throw new Error(`:effect watches unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`);
1147
+ ctx.comp.assets.runtime = true;
1148
+ const watch = [key, ...segs.slice(1)].join(".");
1149
+ const action = parseAction(match[2], ctx);
1150
+ const actions = Array.isArray(action) ? action : [action];
1151
+ return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
1152
+ }
1153
+
1034
1154
  /**
1035
1155
  * @param {string} line
1036
1156
  * @param {string[]} truthyLines
@@ -1039,48 +1159,71 @@ function handleButton(line, ctx) {
1039
1159
  * @returns {string}
1040
1160
  */
1041
1161
  function handleIf(line, truthyLines, falsyLines, ctx) {
1042
- const match = line.match(/^:if\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*$/);
1043
- if (!match) throw new Error(`Malformed :if in ${ctx.file}: ${line}. Use ":if name" with a :state or in-scope value.`);
1044
- const segs = match[1].split(".");
1045
- const head = segs[0];
1162
+ const condition = line.replace(/^:if\s+/, "").trim();
1163
+ // Fast path: a bare truthy dotted path (`:if open`, `:if item.done`). Keeps the
1164
+ // existing markup/behavior identical. Anything with operators (`>`, `==`, `and`,
1165
+ // `not`, …) falls through to the predicate path below.
1166
+ const match = condition.match(/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)$/);
1167
+ if (match) {
1168
+ const segs = match[1].split(".");
1169
+ const head = segs[0];
1170
+
1171
+ // Per-row meta vars in :if — only valid inside a loop.
1172
+ if (LOOP_META[head]) {
1173
+ 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.`);
1174
+ if (ctx.loopItem) {
1175
+ const truthy = compileBody(truthyLines, ctx).trim();
1176
+ const falsy = compileBody(falsyLines, ctx).trim();
1177
+ 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>`;
1178
+ }
1179
+ // static: the meta boolean is in scope — fall through to the static branch below.
1180
+ }
1046
1181
 
1047
- // Per-row meta vars in :if — only valid inside a loop.
1048
- if (LOOP_META[head]) {
1049
- 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.`);
1050
- if (ctx.loopItem) {
1182
+ const staticValue = lookupVar(ctx.scope, head);
1183
+ if (staticValue.found) {
1184
+ const active = Boolean(getPath(staticValue.value, segs.slice(1)));
1185
+ return compileBody(active ? truthyLines : falsyLines, ctx);
1186
+ }
1187
+
1188
+ if (ctx.loopItem && head === ctx.loopItem) {
1051
1189
  const truthy = compileBody(truthyLines, ctx).trim();
1052
1190
  const falsy = compileBody(falsyLines, ctx).trim();
1053
- 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>`;
1191
+ const rest = segs.slice(1).join(".");
1192
+ const pathAttr = ` data-wd-path="${escapeHtml(rest)}"`;
1193
+ return `<span data-wd-each-if${pathAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
1054
1194
  }
1055
- // static: the meta boolean is in scope — fall through to the static branch below.
1056
- }
1057
-
1058
- const staticValue = lookupVar(ctx.scope, head);
1059
- if (staticValue.found) {
1060
- const active = Boolean(getPath(staticValue.value, segs.slice(1)));
1061
- return compileBody(active ? truthyLines : falsyLines, ctx);
1062
- }
1063
1195
 
1064
- if (ctx.loopItem && head === ctx.loopItem) {
1196
+ const key = resolveStateKey(head, ctx);
1197
+ if (!key) {
1198
+ throw new Error(`:if ${match[1]} in ${ctx.file} does not match a :state or in-scope value. Declare it first.`);
1199
+ }
1200
+ ctx.comp.assets.runtime = true;
1065
1201
  const truthy = compileBody(truthyLines, ctx).trim();
1066
1202
  const falsy = compileBody(falsyLines, ctx).trim();
1067
- const rest = segs.slice(1).join(".");
1068
- const pathAttr = ` data-wd-path="${escapeHtml(rest)}"`;
1069
- return `<span data-wd-each-if${pathAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
1070
- }
1071
-
1072
- const key = resolveStateKey(head, ctx);
1073
- if (!key) {
1074
- throw new Error(`:if ${match[1]} in ${ctx.file} does not match a :state or in-scope value. Declare it first.`);
1075
- }
1203
+ const restPath = segs.slice(1).join(".");
1204
+ const pathAttr = restPath ? ` data-wd-path="${escapeHtml(restPath)}"` : "";
1205
+ const initialTruthy = Boolean(getPath(ctx.comp.state.get(key), segs.slice(1)));
1206
+ const active = initialTruthy ? truthy : falsy;
1207
+ 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>`;
1208
+ }
1209
+
1210
+ // Predicate path: a comparison / logical condition. Compiles through the same
1211
+ // whitelist as `@loop … where` / `.class when` (with `not`), so it folds at
1212
+ // build when static, drives a per-row each-if when it reads the loop item, and
1213
+ // a global if-region (evaluated each render) when it reads state. No raw eval.
1214
+ if (!condition) throw new Error(`Malformed :if in ${ctx.file}: ${line}. Use ":if name" or ":if a <op> b [and|or|not …]".`);
1215
+ const compiled = compileWhen(condition, ctx, '":if"');
1216
+ if (compiled.static) return compileBody(compiled.value ? truthyLines : falsyLines, ctx);
1076
1217
  ctx.comp.assets.runtime = true;
1077
1218
  const truthy = compileBody(truthyLines, ctx).trim();
1078
1219
  const falsy = compileBody(falsyLines, ctx).trim();
1079
- const restPath = segs.slice(1).join(".");
1080
- const pathAttr = restPath ? ` data-wd-path="${escapeHtml(restPath)}"` : "";
1081
- const initialTruthy = Boolean(getPath(ctx.comp.state.get(key), segs.slice(1)));
1220
+ const exprAttr = ` data-wd-if-expr="${escapeHtml(compiled.body)}"`;
1221
+ if (compiled.item) {
1222
+ return `<span data-wd-each-if${exprAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
1223
+ }
1224
+ const initialTruthy = evalPredicate(compiled.body, undefined, ctx);
1082
1225
  const active = initialTruthy ? truthy : falsy;
1083
- 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>`;
1226
+ return `<div data-wd-if=""${exprAttr} 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>`;
1084
1227
  }
1085
1228
 
1086
1229
  // The fixed corrective suggestion shown for any malformed @loop header.
@@ -1375,6 +1518,82 @@ function evalPredicate(body, item, ctx) {
1375
1518
  }
1376
1519
  }
1377
1520
 
1521
+ /**
1522
+ * Compile one operand of a `::: … .class when <predicate>` expression. Like the
1523
+ * `@loop where` operand, but also folds a static-scope value (a loop-unrolled
1524
+ * item field or include arg) to its build-time literal, so a fully-static
1525
+ * predicate can be evaluated at compile time. Mirrors `:if`'s scope→item→state
1526
+ * resolution order.
1527
+ * @param {string} tok
1528
+ * @param {Ctx} ctx
1529
+ * @param {string} what Directive label for error messages.
1530
+ * @returns {{ code: string, state: boolean, item: boolean }}
1531
+ */
1532
+ function compileWhenOperand(tok, ctx, what) {
1533
+ if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), state: false, item: false };
1534
+ if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, state: false, item: false };
1535
+ if (["true", "false", "null"].includes(tok)) return { code: tok, state: false, item: false };
1536
+ if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
1537
+ throw new Error(`Unsupported operand "${tok}" in ${what} (${ctx.file}). Use item.field, a :state name, a number, or a "string".`);
1538
+ }
1539
+ const segs = tok.split(".");
1540
+ if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
1541
+ throw new Error(`Path "${tok}" is not allowed in ${what} (${ctx.file})`);
1542
+ }
1543
+ const scoped = lookupVar(ctx.scope, segs[0]);
1544
+ if (scoped.found) return { code: JSON.stringify(getPath(scoped.value, segs.slice(1)) ?? null), state: false, item: false };
1545
+ if (ctx.loopItem && segs[0] === ctx.loopItem) return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, state: false, item: true };
1546
+ const key = resolveStateKey(segs[0], ctx);
1547
+ if (!key) throw new Error(`${what} references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`);
1548
+ const rest = segs.slice(1).join(".");
1549
+ return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, state: true, item: false };
1550
+ }
1551
+
1552
+ /**
1553
+ * Compile a `::: … .class when <predicate>`. Allows `:if`-style bare truthy
1554
+ * paths and `where`-style `left <op> right` conditions joined by `and`/`or`.
1555
+ * Folds to a static verdict when every operand is build-known, else returns a
1556
+ * runtime body over I()/S()/C() plus whether it reads the reactive loop item
1557
+ * (which decides data-wd-each-class vs the global data-wd-class).
1558
+ * @param {string} raw
1559
+ * @param {Ctx} ctx
1560
+ * @param {string} [what] Directive label for error messages.
1561
+ * @returns {{ static: true, value: boolean } | { static: false, body: string, item: boolean }}
1562
+ */
1563
+ function compileWhen(raw, ctx, what = '"::: … when"') {
1564
+ const parts = raw.split(/\s+(and|or)\s+/i);
1565
+ /** @type {string[]} */
1566
+ const pieces = [];
1567
+ let usesState = false;
1568
+ let usesItem = false;
1569
+ for (let i = 0; i < parts.length; i++) {
1570
+ if (i % 2 === 1) { pieces.push(parts[i].toLowerCase() === "and" ? "&&" : "||"); continue; }
1571
+ let seg = parts[i].trim();
1572
+ // Optional leading `not` negates the whole sub-condition.
1573
+ let negate = false;
1574
+ if (/^not\s+/i.test(seg)) { negate = true; seg = seg.replace(/^not\s+/i, "").trim(); }
1575
+ const opMatch = seg.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
1576
+ let expr;
1577
+ if (opMatch) {
1578
+ const left = compileWhenOperand(opMatch[1].trim(), ctx, what);
1579
+ const right = compileWhenOperand(opMatch[3].trim(), ctx, what);
1580
+ usesState = usesState || left.state || right.state;
1581
+ usesItem = usesItem || left.item || right.item;
1582
+ const op = opMatch[2].toLowerCase();
1583
+ expr = op === "contains" ? `C(${left.code}, ${right.code})` : `${left.code} ${op} ${right.code}`;
1584
+ } else {
1585
+ const only = compileWhenOperand(seg, ctx, what);
1586
+ usesState = usesState || only.state;
1587
+ usesItem = usesItem || only.item;
1588
+ expr = only.code;
1589
+ }
1590
+ pieces.push(negate ? `(!(${expr}))` : `(${expr})`);
1591
+ }
1592
+ const body = pieces.join(" ");
1593
+ if (!usesState && !usesItem) return { static: true, value: evalPredicate(body, undefined, ctx) };
1594
+ return { static: false, body, item: usesItem };
1595
+ }
1596
+
1378
1597
  /**
1379
1598
  * @param {unknown[]} rows
1380
1599
  * @param {string} itemName