@zvndev/darkmown 0.12.0 → 0.13.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/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/cli.js CHANGED
@@ -10,7 +10,9 @@ import { initProject } from "./scaffold.js";
10
10
  import { contentType, resolvePublicFile, serve } from "./statics.js";
11
11
 
12
12
  const cliPath = fileURLToPath(import.meta.url);
13
- const command = process.argv[2] || "build";
13
+ // Bare `darkmown` prints help (discovery) rather than silently building — a new
14
+ // user typing the command alone expects to see what it can do, not a no-op build.
15
+ const command = process.argv[2] || "help";
14
16
 
15
17
  if (command === "help" || command === "--help" || command === "-h") {
16
18
  printHelp();
@@ -18,14 +20,24 @@ if (command === "help" || command === "--help" || command === "-h") {
18
20
  const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
19
21
  console.log(pkg.version);
20
22
  } else if (command === "init") {
21
- const target = process.argv[3] || ".";
22
- const result = initProject(path.resolve(process.cwd(), target));
23
- const relativeRoot = path.relative(process.cwd(), result.root) || ".";
24
- console.log(`Created Darkmown project at ${relativeRoot}`);
25
- console.log(`Next: ${nextStep(relativeRoot)}`);
23
+ try {
24
+ const target = process.argv[3] || ".";
25
+ const result = initProject(path.resolve(process.cwd(), target));
26
+ const relativeRoot = path.relative(process.cwd(), result.root) || ".";
27
+ console.log(`Created Darkmown project at ${relativeRoot}`);
28
+ console.log(`Next: ${nextStep(relativeRoot)}`);
29
+ } catch (error) {
30
+ failClean(error);
31
+ }
26
32
  } else if (command === "build") {
27
- const result = buildSite();
28
- console.log(`Built ${result.routes.length} routes into ${path.relative(process.cwd(), result.distRoot)}`);
33
+ try {
34
+ const result = buildSite();
35
+ console.log(`Built ${result.routes.length} routes into ${path.relative(process.cwd(), result.distRoot)}`);
36
+ } catch (error) {
37
+ // A compile error has a clear, file-pathed message — print just that, not a
38
+ // Node stack trace, so a typo reads as "fix your page", not "the tool crashed".
39
+ failClean(error);
40
+ }
29
41
  } else if (command === "dev") {
30
42
  const port = Number(process.env.PORT || 5173);
31
43
  const host = process.env.HOST || "127.0.0.1";
@@ -170,6 +182,17 @@ function broadcast(clients, message) {
170
182
  for (const client of clients) client.write(message);
171
183
  }
172
184
 
185
+ /**
186
+ * Print a compile/CLI error as a single clean line and exit non-zero — no Node
187
+ * stack trace. The thrown Error already carries the file path + corrective hint.
188
+ * @param {unknown} error
189
+ * @returns {never}
190
+ */
191
+ function failClean(error) {
192
+ console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);
193
+ process.exit(1);
194
+ }
195
+
173
196
  function printHelp() {
174
197
  console.log(`Darkmown
175
198
 
package/src/compiler.js CHANGED
@@ -110,13 +110,24 @@ export function compilePage(file, context) {
110
110
  const compiled = compileDocument(file, context);
111
111
  const title = compiled.meta.title || "Darkmown";
112
112
  const description = compiled.meta.description || "";
113
- const descriptionTag = description
114
- ? `\n <meta name="description" content="${escapeHtml(description)}">
115
- <meta property="og:title" content="${escapeHtml(title)}">
116
- <meta property="og:description" content="${escapeHtml(description)}">
117
- <meta property="og:type" content="website">
118
- <meta name="twitter:card" content="summary">`
119
- : "";
113
+ // Optional `image:` frontmatter sets the social-share preview (absolute URL).
114
+ const image = typeof compiled.meta.image === "string" ? compiled.meta.image : "";
115
+ /** @type {string[]} */
116
+ const social = [];
117
+ if (description) social.push(`<meta name="description" content="${escapeHtml(description)}">`);
118
+ if (description || image) {
119
+ social.push(`<meta property="og:title" content="${escapeHtml(title)}">`);
120
+ if (description) social.push(`<meta property="og:description" content="${escapeHtml(description)}">`);
121
+ social.push(`<meta property="og:type" content="website">`);
122
+ }
123
+ if (image) {
124
+ social.push(`<meta property="og:image" content="${escapeHtml(image)}">`);
125
+ social.push(`<meta name="twitter:image" content="${escapeHtml(image)}">`);
126
+ }
127
+ if (description || image) {
128
+ social.push(`<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}">`);
129
+ }
130
+ const descriptionTag = social.length ? `\n ${social.join("\n ")}` : "";
120
131
  const favicon = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='6'%20fill='%2318221d'/%3E%3Ctext%20x='16'%20y='23'%20text-anchor='middle'%20font-family='Georgia,serif'%20font-size='19'%20font-weight='bold'%20fill='%23f7f3ea'%3ED%3C/text%3E%3C/svg%3E";
121
132
  const cssLinks = [...compiled.assets.skins].map((href) => `<link rel="stylesheet" href="${href}">`).join("\n");
122
133
  const scriptSrcs = compiled.assets.runtime ? ["/__wd/runtime.js", ...compiled.assets.scripts] : [...compiled.assets.scripts];
@@ -366,6 +377,11 @@ function compileBody(lines, ctx) {
366
377
  out.push(handleComputed(line, ctx));
367
378
  continue;
368
379
  }
380
+ if (/^:effect\s/.test(line)) {
381
+ flush();
382
+ out.push(handleEffect(line, ctx));
383
+ continue;
384
+ }
369
385
  if (/^:form\s/.test(line)) {
370
386
  flush();
371
387
  const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx.file);
@@ -531,7 +547,13 @@ function scanContainer(lines, start, file) {
531
547
  }
532
548
 
533
549
  /**
534
- * 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.
535
557
  * @param {string[]} lines
536
558
  * @param {number} start Index of the `:if` line.
537
559
  * @param {string} file Source path for errors.
@@ -543,6 +565,11 @@ function scanConditional(lines, start, file) {
543
565
  /** @type {string[]} */
544
566
  const falsy = [];
545
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";
546
573
  let depth = 0;
547
574
  let fence = null;
548
575
  for (let i = start + 1; i < lines.length; i++) {
@@ -560,12 +587,28 @@ function scanConditional(lines, start, file) {
560
587
  }
561
588
  if (/^:if\s/.test(line)) depth++;
562
589
  if (line.trim() === ":endif") {
563
- 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
+ }
564
594
  depth--;
565
595
  }
566
- if (line.trim() === ":else" && depth === 0) {
567
- current = falsy;
568
- 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
+ }
569
612
  }
570
613
  current.push(line);
571
614
  }
@@ -623,20 +666,44 @@ function parseIncludeArgs(raw, ctx) {
623
666
  * @returns {string}
624
667
  */
625
668
  function handleContainer(header, bodyLines, ctx) {
626
- const tokens = header.split(/\s+/);
669
+ let rest = header.trim();
627
670
  let tag = "section";
628
- /** @type {string[]} */
629
- 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 = [];
630
677
  let id = "";
631
- let nameToken = tokens[0] && !tokens[0].startsWith("#") && !tokens[0].startsWith(".") ? tokens.shift() ?? "section" : "section";
632
- if (nameToken !== "section") {
633
- tag = "div";
634
- extraClass.push(nameToken);
635
- }
636
- for (const token of tokens) {
637
- if (token.startsWith("#")) id = token.slice(1);
638
- else if (token.startsWith(".")) extraClass.push(token.slice(1));
639
- 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
+ }
640
707
  }
641
708
  const explicitId = Boolean(id);
642
709
  if (!id) id = `wd-s${++ctx.comp.sectionCounter}`;
@@ -650,7 +717,10 @@ function handleContainer(header, bodyLines, ctx) {
650
717
  }
651
718
  const idAttr = explicitId ? ` id="${escapeHtml(id)}"` : "";
652
719
  const classAttr = extraClass.length ? ` class="${escapeHtml(extraClass.join(" "))}"` : "";
653
- 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}>`;
654
724
  }
655
725
 
656
726
  /**
@@ -729,7 +799,7 @@ function declareErrorState(key, ctx) {
729
799
  }
730
800
 
731
801
  const FETCH_USE =
732
- '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]';
733
803
 
734
804
  /**
735
805
  * Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
@@ -744,14 +814,14 @@ function handleFetch(line, ctx) {
744
814
  const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
745
815
  if (!head) throw new Error(`Malformed :fetch in ${ctx.file}: ${line}. ${FETCH_USE}`);
746
816
  const name = head[1];
747
- const url = stripQuotes(head[2]);
817
+ const url = validateFetchUrl(stripQuotes(head[2]), ctx);
748
818
  /** @type {Record<string, string>} */
749
819
  const opts = {};
750
820
  for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
751
821
  const kv = part.match(/^([A-Za-z]+)=(.+)$/);
752
822
  if (!kv) throw new Error(`Unknown :fetch option "${part}" in ${ctx.file}. ${FETCH_USE}`);
753
823
  const optName = kv[1];
754
- if (!["method", "when", "timeout", "retry", "headers", "body"].includes(optName)) {
824
+ if (!["method", "when", "timeout", "retry", "headers", "body", "refresh"].includes(optName)) {
755
825
  throw new Error(`Unknown :fetch option "${optName}" in ${ctx.file}. ${FETCH_USE}`);
756
826
  }
757
827
  opts[optName] = stripQuotes(kv[2]);
@@ -768,6 +838,14 @@ function handleFetch(line, ctx) {
768
838
  throw new Error(`:fetch ${n} must be a non-negative integer in ${ctx.file}. ${FETCH_USE}`);
769
839
  }
770
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
+ }
771
849
 
772
850
  // Extract `{ path }` dependency keys from the URL (validated against poison
773
851
  // segments). The runtime fills them from state on each (re)fetch.
@@ -800,11 +878,40 @@ function handleFetch(line, ctx) {
800
878
  attr("retry", opts.retry) +
801
879
  attr("headers", opts.headers && resolveStateKey(opts.headers, ctx)) +
802
880
  attr("body", opts.body && resolveStateKey(opts.body, ctx)) +
881
+ attr("refresh", opts.refresh) +
803
882
  attr("deps", deps.join(",")) +
804
883
  `></span>`;
805
884
  return `<script type="application/json" data-wd-state>${safeScriptJson(seeds)}</script>${marker}`;
806
885
  }
807
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
+
808
915
  /**
809
916
  * @param {string} line
810
917
  * @param {Ctx} ctx
@@ -1020,6 +1127,30 @@ function handleButton(line, ctx) {
1020
1127
  return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
1021
1128
  }
1022
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
+
1023
1154
  /**
1024
1155
  * @param {string} line
1025
1156
  * @param {string[]} truthyLines
@@ -1364,6 +1495,75 @@ function evalPredicate(body, item, ctx) {
1364
1495
  }
1365
1496
  }
1366
1497
 
1498
+ /**
1499
+ * Compile one operand of a `::: … .class when <predicate>` expression. Like the
1500
+ * `@loop where` operand, but also folds a static-scope value (a loop-unrolled
1501
+ * item field or include arg) to its build-time literal, so a fully-static
1502
+ * predicate can be evaluated at compile time. Mirrors `:if`'s scope→item→state
1503
+ * resolution order.
1504
+ * @param {string} tok
1505
+ * @param {Ctx} ctx
1506
+ * @returns {{ code: string, state: boolean, item: boolean }}
1507
+ */
1508
+ function compileWhenOperand(tok, ctx) {
1509
+ if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), state: false, item: false };
1510
+ if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, state: false, item: false };
1511
+ if (["true", "false", "null"].includes(tok)) return { code: tok, state: false, item: false };
1512
+ if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
1513
+ throw new Error(`Unsupported operand "${tok}" in "::: … when" (${ctx.file}). Use item.field, a :state name, a number, or a "string".`);
1514
+ }
1515
+ const segs = tok.split(".");
1516
+ if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
1517
+ throw new Error(`Path "${tok}" is not allowed in "::: … when" (${ctx.file})`);
1518
+ }
1519
+ const scoped = lookupVar(ctx.scope, segs[0]);
1520
+ if (scoped.found) return { code: JSON.stringify(getPath(scoped.value, segs.slice(1)) ?? null), state: false, item: false };
1521
+ if (ctx.loopItem && segs[0] === ctx.loopItem) return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, state: false, item: true };
1522
+ const key = resolveStateKey(segs[0], ctx);
1523
+ if (!key) throw new Error(`"::: … when" references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`);
1524
+ const rest = segs.slice(1).join(".");
1525
+ return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, state: true, item: false };
1526
+ }
1527
+
1528
+ /**
1529
+ * Compile a `::: … .class when <predicate>`. Allows `:if`-style bare truthy
1530
+ * paths and `where`-style `left <op> right` conditions joined by `and`/`or`.
1531
+ * Folds to a static verdict when every operand is build-known, else returns a
1532
+ * runtime body over I()/S()/C() plus whether it reads the reactive loop item
1533
+ * (which decides data-wd-each-class vs the global data-wd-class).
1534
+ * @param {string} raw
1535
+ * @param {Ctx} ctx
1536
+ * @returns {{ static: true, value: boolean } | { static: false, body: string, item: boolean }}
1537
+ */
1538
+ function compileWhen(raw, ctx) {
1539
+ const parts = raw.split(/\s+(and|or)\s+/i);
1540
+ /** @type {string[]} */
1541
+ const pieces = [];
1542
+ let usesState = false;
1543
+ let usesItem = false;
1544
+ for (let i = 0; i < parts.length; i++) {
1545
+ if (i % 2 === 1) { pieces.push(parts[i].toLowerCase() === "and" ? "&&" : "||"); continue; }
1546
+ const seg = parts[i].trim();
1547
+ const opMatch = seg.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
1548
+ if (opMatch) {
1549
+ const left = compileWhenOperand(opMatch[1].trim(), ctx);
1550
+ const right = compileWhenOperand(opMatch[3].trim(), ctx);
1551
+ usesState = usesState || left.state || right.state;
1552
+ usesItem = usesItem || left.item || right.item;
1553
+ const op = opMatch[2].toLowerCase();
1554
+ pieces.push(op === "contains" ? `(C(${left.code}, ${right.code}))` : `(${left.code} ${op} ${right.code})`);
1555
+ } else {
1556
+ const only = compileWhenOperand(seg, ctx);
1557
+ usesState = usesState || only.state;
1558
+ usesItem = usesItem || only.item;
1559
+ pieces.push(`(${only.code})`);
1560
+ }
1561
+ }
1562
+ const body = pieces.join(" ");
1563
+ if (!usesState && !usesItem) return { static: true, value: evalPredicate(body, undefined, ctx) };
1564
+ return { static: false, body, item: usesItem };
1565
+ }
1566
+
1367
1567
  /**
1368
1568
  * @param {unknown[]} rows
1369
1569
  * @param {string} itemName
package/src/runtime.js CHANGED
@@ -131,7 +131,11 @@ function isEmpty(v) {
131
131
  async function httpJson(url, init) {
132
132
  const response = await fetch(url, init);
133
133
  const text = await response.text();
134
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
134
+ if (!response.ok) {
135
+ const err = /** @type {Error & { status?: number }} */ (new Error(`HTTP ${response.status}`));
136
+ err.status = response.status; // lets the :fetch lifecycle single out a 401 for token refresh
137
+ throw err;
138
+ }
135
139
  try { return JSON.parse(text); } catch { return { status: response.status, body: text }; }
136
140
  }
137
141
 
@@ -186,6 +190,29 @@ function loopPredicate(body) {
186
190
  return fn;
187
191
  }
188
192
 
193
+ /**
194
+ * Toggle reactive `.class when <predicate>` bindings on an element. `raw` is the
195
+ * JSON `[[name, body], …]` from data-wd-class (global, item undefined) or
196
+ * data-wd-each-class (per loop row); each body is the same I()/S()/C()
197
+ * expression the loop `where` uses.
198
+ * @param {Element} el
199
+ * @param {string | null} raw
200
+ * @param {any} item Loop item for each-class; undefined for a global class.
201
+ * @returns {void}
202
+ */
203
+ function classToggle(el, raw, item) {
204
+ if (!raw) return;
205
+ /** @type {[string, string][]} */
206
+ let pairs;
207
+ try { pairs = JSON.parse(raw); } catch { return; }
208
+ for (const [name, body] of pairs) {
209
+ let on = false;
210
+ try { on = Boolean(loopPredicate(body)((/** @type {string} */ p) => getPath(item, p), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn)); }
211
+ catch (error) { warn(body, error); }
212
+ el.classList.toggle(name, on);
213
+ }
214
+ }
215
+
189
216
  /**
190
217
  * Stable per-render key for a loop row, disambiguating duplicates with `#n`.
191
218
  * @param {any} item
@@ -231,6 +258,8 @@ function fillItem(node, item, meta = {}) {
231
258
  for (const marker of node.querySelectorAll("[data-wd-each-meta]")) {
232
259
  marker.textContent = meta[marker.getAttribute("data-wd-each-meta") || ""] ?? "";
233
260
  }
261
+ const classNodes = node.matches("[data-wd-each-class]") ? [node, ...node.querySelectorAll("[data-wd-each-class]")] : [...node.querySelectorAll("[data-wd-each-class]")];
262
+ for (const el of classNodes) classToggle(el, el.getAttribute("data-wd-each-class"), item);
234
263
  }
235
264
 
236
265
  /**
@@ -290,6 +319,10 @@ function renderNow() {
290
319
  output.innerHTML = template?.innerHTML || "";
291
320
  }
292
321
 
322
+ // Reactive styling: toggle state-driven `.class when …` bindings. Loop-row
323
+ // bindings (data-wd-each-class) are handled per item inside fillItem.
324
+ for (const el of document.querySelectorAll("[data-wd-class]")) classToggle(el, el.getAttribute("data-wd-class"), undefined);
325
+
293
326
  for (const region of document.querySelectorAll("[data-wd-loop]")) {
294
327
  const key = region.getAttribute("data-wd-loop");
295
328
  const data = region.getAttribute("data-wd-loop-data");
@@ -362,6 +395,36 @@ function renderNow() {
362
395
  for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
363
396
  if (document.activeElement !== input) /** @type {HTMLInputElement} */ (input).value = state[input.getAttribute("data-wd-bind-input") || ""] ?? "";
364
397
  }
398
+
399
+ runEffects(); // side effects run last, against fully settled state
400
+ }
401
+
402
+ /** @typedef {{ watch: string, actions: { op: string, target?: string, value?: any }[], last: string }} Effect */
403
+ /** @type {Effect[]} */
404
+ const effects = [...document.querySelectorAll("script[data-wd-effect]")].map((node) => {
405
+ const def = JSON.parse(node.textContent || "{}");
406
+ return { watch: def.watch, actions: def.actions || [], last: JSON.stringify(getPath(state, def.watch) ?? null) };
407
+ });
408
+ let effectDepth = 0;
409
+ /**
410
+ * After a render, fire any `:effect` whose watched state changed, running its
411
+ * actions through the shared action runner. If that mutates state, re-render and
412
+ * re-check; a settle cap of 10 passes guards against effect→effect loops.
413
+ * @returns {void}
414
+ */
415
+ function runEffects() {
416
+ if (!effects.length) return;
417
+ let changed = false;
418
+ for (const fx of effects) {
419
+ const now = JSON.stringify(getPath(state, fx.watch) ?? null);
420
+ if (now === fx.last) continue;
421
+ fx.last = now;
422
+ for (const a of fx.actions) applyAction(null, a.op, a.target || "", a.value);
423
+ changed = true;
424
+ }
425
+ if (!changed) { effectDepth = 0; return; }
426
+ if (++effectDepth >= 10) { effectDepth = 0; console.warn("effect did not settle"); return; }
427
+ renderNow(); // reflect the effect's state changes, then re-check
365
428
  }
366
429
 
367
430
  /**
@@ -391,10 +454,11 @@ document.addEventListener("input", (event) => {
391
454
  /**
392
455
  * Resolve the loop source and row item for a clicked element, if inside a loop.
393
456
  * The clicked button's row is the nearest reconciled loop node; it carries its item.
394
- * @param {Element} el
457
+ * @param {Element | null} el Null for effect-driven actions (no clicked row).
395
458
  * @returns {{ srcKey: string | null, item: any } | null}
396
459
  */
397
460
  function clickedRow(el) {
461
+ if (!el) return null; // effect-driven actions have no clicked row → row ops no-op
398
462
  const row = /** @type {WdRow | null} */ (el.closest("[data-wd-loop-key]"));
399
463
  const region = el.closest("[data-wd-loop]");
400
464
  if (!row || !region) return null;
@@ -404,7 +468,7 @@ function clickedRow(el) {
404
468
  /**
405
469
  * Apply one parsed action `{op,target,value}` against state. Targets are dotted
406
470
  * paths read via getPath / written via setPath; a bare name is a 1-segment path.
407
- * @param {Element} action The clicked element (for loop-row resolution).
471
+ * @param {Element | null} action The clicked element (for loop-row resolution); null for effects.
408
472
  * @param {string} op
409
473
  * @param {string} target
410
474
  * @param {any} value
@@ -518,12 +582,17 @@ function startFetch(node) {
518
582
 
519
583
  const headers = g("headers");
520
584
  const body = g("body");
585
+ const refresh = g("refresh");
521
586
  const method = g("method") || "GET";
522
587
  const timeout = Number(g("timeout"));
523
588
  let tries = Number(g("retry"));
589
+ let refreshed = false; // a token refresh + retry happens at most once per request
524
590
  state[key + "_error"] = null;
525
591
  set("_loading", true);
526
592
 
593
+ /** @param {any} error */
594
+ const fail = (error) => { state[key + "_error"] = String(error); set("_loading", false); };
595
+
527
596
  const attempt = () => {
528
597
  /** @type {RequestInit} */
529
598
  const init = { method };
@@ -542,14 +611,53 @@ function startFetch(node) {
542
611
  }).catch((error) => {
543
612
  clearTimeout(timer);
544
613
  if (dead()) return;
614
+ const is401 = error && error.status === 401;
615
+ // Layer 2: a 401 with a refresh URL renews the token once, then retries.
616
+ if (is401 && refresh && headers && !refreshed) {
617
+ refreshed = true;
618
+ return void refreshToken(refresh, headers).then((ok) => {
619
+ if (dead()) return;
620
+ ok ? attempt() : fail(error);
621
+ });
622
+ }
623
+ if (is401 && refreshed) return void fail(error); // a second 401 after refresh → give up
545
624
  if (tries-- > 0) return void setTimeout(attempt, 200);
546
- state[key + "_error"] = String(error);
547
- set("_loading", false);
625
+ fail(error);
548
626
  });
549
627
  };
550
628
  attempt();
551
629
  }
552
630
 
631
+ /** @type {Map<string, Promise<boolean>>} In-flight token refreshes, keyed by URL. */
632
+ const refreshFlights = new Map();
633
+ /**
634
+ * Single-flight token refresh: POST the current header state to `refreshUrl`,
635
+ * and on a 2xx JSON reply write the returned headers object back into the
636
+ * `headersKey` state (persisting if it is a `:store`). Concurrent 401s sharing a
637
+ * refresh URL await the same request, then each retries its own fetch.
638
+ * @param {string} refreshUrl
639
+ * @param {string} headersKey
640
+ * @returns {Promise<boolean>} whether the token was renewed
641
+ */
642
+ function refreshToken(refreshUrl, headersKey) {
643
+ const inflight = refreshFlights.get(refreshUrl);
644
+ if (inflight) return inflight;
645
+ const flight = httpJson(refreshUrl, {
646
+ method: "POST",
647
+ headers: { "content-type": "application/json" },
648
+ body: JSON.stringify(state[headersKey] ?? {})
649
+ }).then((value) => {
650
+ if (value && typeof value === "object") {
651
+ state[headersKey] = value; // the new headers shape, e.g. { Authorization: "Bearer …" }
652
+ savePersisted();
653
+ return true;
654
+ }
655
+ return false;
656
+ }).catch(() => false).finally(() => refreshFlights.delete(refreshUrl));
657
+ refreshFlights.set(refreshUrl, flight);
658
+ return flight;
659
+ }
660
+
553
661
  /** A fetch marker carrying its last dependency snapshot for refetch diffing and
554
662
  * a generation token so a superseded in-flight response/retry bails on write. */
555
663
  /** @typedef {Element & { __wdSnap?: string, __wdGen?: number }} FetchNode */
package/src/statics.js CHANGED
@@ -65,6 +65,7 @@ const contentTypes = {
65
65
  ".jpeg": "image/jpeg",
66
66
  ".gif": "image/gif",
67
67
  ".webp": "image/webp",
68
+ ".avif": "image/avif",
68
69
  ".ico": "image/x-icon",
69
70
  ".woff": "font/woff",
70
71
  ".woff2": "font/woff2",