@zvndev/darkmown 2.6.1-next.11.g82bb8d7 → 2.7.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.
Files changed (73) hide show
  1. package/AGENTS.md +392 -77
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +316 -49
  4. package/docs/cli.md +51 -17
  5. package/docs/spec-alignment.md +25 -4
  6. package/grammar/wd-directives.gbnf +13 -5
  7. package/package.json +7 -3
  8. package/site/_/base.skin +1 -1
  9. package/site/_/footer.wd +1 -1
  10. package/site/pages/api-errors/index.wd +81 -0
  11. package/site/pages/app.wd +1 -1
  12. package/site/pages/attr-binding/index.wd +84 -0
  13. package/site/pages/blog/darkmown-2-7-0.md +176 -0
  14. package/site/pages/blog/darkmown-2-7-0.skin +127 -0
  15. package/site/pages/blog/no-unsafe-eval.md +66 -0
  16. package/site/pages/blog/no-unsafe-eval.skin +127 -0
  17. package/site/pages/blog/zero-js-by-default.md +1 -1
  18. package/site/pages/branch-state/index.wd +68 -0
  19. package/site/pages/docs/index.wd +320 -19
  20. package/site/pages/index.wd +3 -1
  21. package/site/pages/playground/index.js +63 -56
  22. package/site/pages/pulse/index.wd +1 -1
  23. package/site/pages/settings/index.wd +97 -0
  24. package/site/pages/showcase/index.wd +37 -1
  25. package/site/pages/upload/index.wd +63 -0
  26. package/site/pages/url-state/index.wd +70 -0
  27. package/src/builder.js +222 -34
  28. package/src/catalog.js +217 -21
  29. package/src/cli.js +6 -3
  30. package/src/compiler/actions.js +80 -12
  31. package/src/compiler/body.js +104 -28
  32. package/src/compiler/collections.js +29 -13
  33. package/src/compiler/context.js +9 -2
  34. package/src/compiler/expr-ast.js +14 -0
  35. package/src/compiler/fetch.js +8 -4
  36. package/src/compiler/forms.js +133 -14
  37. package/src/compiler/frontmatter.js +28 -7
  38. package/src/compiler/includes.js +46 -6
  39. package/src/compiler/interpolation.js +56 -1
  40. package/src/compiler/loops.js +489 -35
  41. package/src/compiler/markdown.js +457 -44
  42. package/src/compiler/page.js +8 -3
  43. package/src/compiler/predicates.js +84 -14
  44. package/src/compiler/state.js +167 -18
  45. package/src/compiler/structure.js +159 -29
  46. package/src/errors.js +111 -3
  47. package/src/feeds.js +140 -7
  48. package/src/grammar.js +53 -10
  49. package/src/runtime.js +509 -130
  50. package/src/runtime.min.js +2 -0
  51. package/src/runtime.min.js.map +1 -0
  52. package/src/statics.js +46 -2
  53. package/src/templates/blog/README.md +2 -0
  54. package/src/templates/dashboard/README.md +2 -0
  55. package/src/templates/landing/README.md +2 -0
  56. package/src/templates/starter/README.md +1 -1
  57. package/src/templates/store/README.md +2 -0
  58. package/src/tools/validate.js +14 -4
  59. package/types/builder.d.ts +6 -2
  60. package/types/catalog.d.ts +12 -0
  61. package/types/compiler/actions.d.ts +1 -1
  62. package/types/compiler/body.d.ts +0 -3
  63. package/types/compiler/forms.d.ts +12 -1
  64. package/types/compiler/frontmatter.d.ts +8 -1
  65. package/types/compiler/includes.d.ts +16 -2
  66. package/types/compiler/interpolation.d.ts +23 -0
  67. package/types/compiler/loops.d.ts +47 -0
  68. package/types/compiler/markdown.d.ts +63 -1
  69. package/types/compiler/predicates.d.ts +7 -1
  70. package/types/compiler/state.d.ts +8 -2
  71. package/types/compiler/structure.d.ts +32 -1
  72. package/types/feeds.d.ts +68 -2
  73. package/types/statics.d.ts +25 -0
package/src/runtime.js CHANGED
@@ -17,42 +17,144 @@ const persistKeys = new Set();
17
17
  /** @type {Set<string>} Non-ephemeral :store names, persisted under wd:store:<name>. */
18
18
  const storeKeys = new Set();
19
19
 
20
- for (const script of document.querySelectorAll("script[data-wd-state]")) {
21
- Object.assign(state, JSON.parse(script.textContent || "{}"));
22
- const persist = script.getAttribute("data-wd-persist");
23
- if (persist) persistKeys.add(persist);
24
- }
25
- for (const script of document.querySelectorAll("script[data-wd-store]")) {
26
- const name = script.getAttribute("data-wd-store") || "";
27
- state[name] = JSON.parse(script.textContent || "null");
28
- if (!script.hasAttribute("data-wd-store-ephemeral")) storeKeys.add(name);
29
- }
20
+ /** @type {Set<string>} Keys a `:state … from-url` mirrors into the query string. */
21
+ const urlKeys = new Set();
30
22
 
31
- // Frozen seed snapshot, taken BEFORE localStorage overrides below, so `reset`
32
- // deep-clones the declared value — not the last persisted one.
23
+ // Seed snapshot, recorded per key BEFORE its localStorage/URL override, so
24
+ // `reset` deep-clones the DECLARED value — not the last persisted one.
33
25
  /** @type {Record<string, any>} */
34
- const initials = Object.freeze(JSON.parse(JSON.stringify(state)));
26
+ const initials = {};
35
27
 
36
- for (const key of persistKeys) {
37
- const stored = localStorage.getItem(`wd:${key}`);
38
- if (stored === null) continue;
28
+ /**
29
+ * Best-effort localStorage. Touching it THROWS outright in a sandboxed iframe or
30
+ * a browser set to block site data, and setItem throws on quota — persistence
31
+ * must never abort the boot or stop a render, so every access is swallowed here.
32
+ * `ls(k)` reads, `ls(k, v)` writes, `ls(k, null)` drops a corrupt entry.
33
+ * @param {string} k
34
+ * @param {string | null} [v]
35
+ * @returns {any}
36
+ */
37
+ function ls(k, v) {
39
38
  try {
40
- state[key] = JSON.parse(stored);
41
- } catch {
42
- localStorage.removeItem(`wd:${key}`);
39
+ return v === undefined ? localStorage.getItem(k) : v === null ? localStorage.removeItem(k) : localStorage.setItem(k, v);
40
+ } catch { return null; }
41
+ }
42
+
43
+ /** The query-string parameter a state key mirrors to (`cart:items` → `cart.items`). */
44
+ const urlParam = (/** @type {string} */ key) => key.replace(":", ".");
45
+
46
+ /**
47
+ * Claim every seed `<script>` in the LIVE tree: apply the declared value for any
48
+ * key state does not already hold, record it as that key's reset seed, then let
49
+ * localStorage and the query string override it in that order.
50
+ *
51
+ * Boot runs this over the document; `scan()` runs it again whenever a render
52
+ * changes structure. That second call is the point: `querySelectorAll` cannot
53
+ * see into `<template>` content, so a `:state`/`:store`/`:theme` declared inside
54
+ * an unopened `:if` branch was never hydrated at all — it started undefined and
55
+ * never persisted. Re-opening a branch injects FRESH script nodes (the branch is
56
+ * cloned from a template each time), so the guard here is "state does not have
57
+ * this key yet", not a per-node claim: re-opening a branch must not reset what
58
+ * the reader already changed.
59
+ * @returns {void}
60
+ */
61
+ function hydrate() {
62
+ for (const script of document.querySelectorAll("script[data-wd-state],script[data-wd-store]")) {
63
+ const store = script.getAttribute("data-wd-store");
64
+ const seed = JSON.parse(script.textContent || "null");
65
+ for (const [key, value] of store ? [[store, seed]] : Object.entries(seed || {})) {
66
+ if (key in state) continue;
67
+ // The seed is the reset baseline, so state gets a COPY: mutating a list
68
+ // through an action must not rewrite what `reset` restores.
69
+ state[key] = structuredClone((initials[key] = value));
70
+ const durable = store ? !script.hasAttribute("data-wd-store-ephemeral") : script.getAttribute("data-wd-persist") === key;
71
+ if (durable) {
72
+ (store ? storeKeys : persistKeys).add(key);
73
+ const lsKey = (store ? "wd:store:" : "wd:") + key;
74
+ const stored = ls(lsKey);
75
+ // A corrupt entry self-heals: drop it and keep the declared seed, rather
76
+ // than re-reading the same garbage on every load. A :store with nothing
77
+ // stored yet writes its seed so other tabs see it.
78
+ if (stored === null) { if (store) ls(lsKey, JSON.stringify(value ?? null)); }
79
+ else if (jsonOr(stored) === undefined) ls(lsKey, null);
80
+ else state[key] = jsonOr(stored);
81
+ }
82
+ // `from-url` outranks both: a shared link must show what it says.
83
+ if (script.getAttribute("data-wd-url") === key) {
84
+ urlKeys.add(key);
85
+ readUrl(false, [key]);
86
+ }
87
+ }
43
88
  }
44
89
  }
45
- for (const name of storeKeys) {
46
- const stored = localStorage.getItem(`wd:store:${name}`);
47
- if (stored === null) localStorage.setItem(`wd:store:${name}`, JSON.stringify(state[name] ?? null));
48
- else try { state[name] = JSON.parse(stored); } catch { /** keep seed */ }
90
+
91
+ /**
92
+ * Mirror every `from-url` key into the address bar. A value equal to its
93
+ * declared seed DROPS its parameter, so the default page keeps a clean URL, and
94
+ * the write is `replaceState`, so filtering never fills the back button.
95
+ * @returns {void}
96
+ */
97
+ function syncUrl() {
98
+ if (!urlKeys.size) return;
99
+ const params = new URLSearchParams(location.search);
100
+ for (const key of urlKeys) {
101
+ const value = state[key];
102
+ if (JSON.stringify(value ?? null) === JSON.stringify(initials[key] ?? null)) params.delete(urlParam(key));
103
+ else params.set(urlParam(key), typeof value === "string" ? value : JSON.stringify(value));
104
+ }
105
+ const query = params.toString();
106
+ const next = location.pathname + (query ? `?${query}` : "") + location.hash;
107
+ if (next === location.pathname + location.search + location.hash) return;
108
+ // about:srcdoc (the playground's preview iframe) answers replaceState with a
109
+ // SecurityError. This call sits between the state write and the render in
110
+ // every handler, so an unguarded throw froze the preview on the first
111
+ // keystroke. Like `ls`, URL sync is best-effort and never blocks a paint.
112
+ try { history.replaceState(history.state, "", next); } catch { /* history is not writable here */ }
113
+ }
114
+
115
+ /**
116
+ * Re-read every `from-url` key from the address bar. `restore` is true for a
117
+ * popstate, where a parameter that is GONE means "back to the declared value";
118
+ * it is false while hydrating, where an absent parameter must leave the
119
+ * persisted value the storage read just restored.
120
+ * `only` narrows the read to the keys just claimed by `hydrate`: re-reading the
121
+ * WHOLE set there would revert a write the page has already made but not yet
122
+ * mirrored, which is exactly what happens when an `:effect` writes a `from-url`
123
+ * key and the same action opens a branch declaring another one.
124
+ * @param {boolean} [restore]
125
+ * @param {string[]} [only]
126
+ * @returns {void}
127
+ */
128
+ function readUrl(restore, only) {
129
+ const params = new URLSearchParams(location.search);
130
+ for (const key of only || urlKeys) {
131
+ const raw = params.get(urlParam(key));
132
+ const seed = initials[key];
133
+ if (raw == null) { if (restore) state[key] = structuredClone(seed); }
134
+ else state[key] = typeof seed === "string" ? raw : jsonOr(raw) ?? raw;
135
+ }
49
136
  }
50
137
 
51
138
  function savePersisted() {
52
- for (const key of persistKeys) localStorage.setItem(`wd:${key}`, JSON.stringify(state[key] ?? null));
53
- for (const name of storeKeys) localStorage.setItem(`wd:store:${name}`, JSON.stringify(state[name] ?? null));
139
+ for (const key of persistKeys) ls(`wd:${key}`, JSON.stringify(state[key] ?? null));
140
+ for (const name of storeKeys) ls(`wd:store:${name}`, JSON.stringify(state[name] ?? null));
141
+ syncUrl();
54
142
  }
55
143
 
144
+ hydrate();
145
+
146
+ // Back/forward across `replaceState` history entries, and any other navigation
147
+ // that changes the query string without a reload.
148
+ window.addEventListener("popstate", () => {
149
+ if (!urlKeys.size) return;
150
+ readUrl(true);
151
+ // Storage follows the URL. Boot reads URL > storage > seed, so leaving a
152
+ // persisted value behind after a back to a parameter-less URL would show the
153
+ // seed now and flip back to the stored value on the next reload.
154
+ savePersisted();
155
+ render();
156
+ });
157
+
56
158
  window.addEventListener("storage", (event) => {
57
159
  const name = event.key && event.key.startsWith("wd:store:") ? event.key.slice(9) : "";
58
160
  if (!storeKeys.has(name) || event.newValue == null) return;
@@ -119,6 +221,75 @@ function isEmpty(v) {
119
221
  return v == null || (typeof v === "object" && (Array.isArray(v) ? v.length : Object.keys(v).length) === 0);
120
222
  }
121
223
 
224
+ /**
225
+ * Parse JSON, or undefined when the text is not JSON. `undefined` is a safe
226
+ * sentinel: no JSON document parses to it, so a body of literal `null` is still
227
+ * distinguishable from "not JSON at all".
228
+ * @param {string} text
229
+ * @returns {any}
230
+ */
231
+ function jsonOr(text) {
232
+ try { return JSON.parse(text); } catch { return undefined; }
233
+ }
234
+
235
+ // The characters a value must not carry into a URL, mirroring `DEST_UNSAFE`
236
+ // in src/compiler/markdown.js: an unencoded space or paren makes the paint and
237
+ // the re-render disagree, and `tests/attr-binding.test.js` pins the two together.
238
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — a control character in a URL has to be encoded, not passed through.
239
+ const URL_UNSAFE = /[\u0000-\u0020()<>"'`\\\u007F]/g;
240
+
241
+ /**
242
+ * Every node matching `sel` at or under `node` — the self-match matters because
243
+ * a loop row's own element can carry the marker, not just its descendants.
244
+ * @param {Element} node
245
+ * @param {string} sel
246
+ * @returns {Element[]}
247
+ */
248
+ const selfAnd = (node, sel) => (node.matches(sel) ? [node, ...node.querySelectorAll(sel)] : [...node.querySelectorAll(sel)]);
249
+
250
+ /**
251
+ * Repaint one bound attribute (a markdown link/image destination that read
252
+ * `:state`, `:computed`, or a loop row). The marker is `[attr, …parts]`, where a
253
+ * part is either a literal string or a reader: `["s", key, path]` for state,
254
+ * `["m", name]` for a row meta variable, `["i", path]` for the row itself.
255
+ *
256
+ * The scheme guard is the load-bearing half. The compiler vets URLs the AUTHOR
257
+ * wrote; the value arriving here came from state, a form field, or a fetched
258
+ * payload, and nothing upstream has seen it. Control characters are stripped
259
+ * before the test because a browser strips them out of a URL before resolving
260
+ * it, so `java<TAB>script:` is a live javascript: URL. An unsafe value is not
261
+ * applied — the attribute is emptied instead. Mirrored by `unsafeUrlValue` in
262
+ * src/compiler/interpolation.js for the build-time paint.
263
+ * @param {Element} el
264
+ * @param {string | null} raw
265
+ * @param {any} item Loop row, or undefined outside one.
266
+ * @param {Record<string, any>} meta Per-row meta values.
267
+ * @returns {void}
268
+ */
269
+ function applyAttr(el, raw, item, meta) {
270
+ if (!raw) return;
271
+ let parts;
272
+ try { parts = JSON.parse(raw); } catch { return; }
273
+ let out = ""; // encoded: what lands on the attribute
274
+ let plain = ""; // raw: what the scheme guard tests
275
+ for (let i = 1; i < parts.length; i++) {
276
+ const p = parts[i];
277
+ // A literal chunk is the author's own URL text, already vetted by the
278
+ // compiler; only a READER value is encoded, and with exactly the character
279
+ // set `encodeDest` uses in src/compiler/markdown.js, or the build-time paint
280
+ // and the first re-render would disagree on every value holding a space.
281
+ if (typeof p === "string") { out += p; plain += p; continue; }
282
+ const v = String((p[0] === "s" ? getPath(state[p[1]], p[2]) : p[0] === "m" ? meta[p[1]] : getPath(item, p[1])) ?? "");
283
+ plain += v;
284
+ out += v.replace(URL_UNSAFE, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
285
+ }
286
+ // Defense in depth: the compiler only ever emits href (a link) or src (an
287
+ // image), so anything else in a marker did not come from a compile.
288
+ if (parts[0] !== "href" && parts[0] !== "src") return;
289
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — a browser strips these out of a URL, so the guard must too.
290
+ el.setAttribute(parts[0], /^(javascript|data|vbscript):/i.test(plain.replace(/[\u0000-\u0020\u007F]+/g, "")) ? "" : out);
291
+ }
292
+
122
293
  // --- Format pipes -----------------------------------------------------------
123
294
  // A compact mirror of src/compiler/format.js: the same formatter math, so a
124
295
  // reactive `{ value | money }` re-formats in the browser exactly as the static
@@ -202,19 +373,53 @@ const AGG = (/** @type {string} */ name, /** @type {any} */ list, /** @type {str
202
373
  async function httpJson(url, init) {
203
374
  const response = await fetch(url, init);
204
375
  const text = await response.text();
376
+ const body = jsonOr(text);
205
377
  if (!response.ok) {
206
- const err = /** @type {Error & { status?: number }} */ (new Error(`HTTP ${response.status}`));
378
+ // A real API says WHY it refused. Prefer the body's own `error`/`message`
379
+ // string over the bare status line, and carry the parsed body along so a
380
+ // page can render field-level errors from `<name>_error_body`.
381
+ // The first NON-EMPTY string of the two: `{ "error": "", "message": "…" }`
382
+ // is a real shape, and `??` would keep the empty string and render "Error".
383
+ const said = body && [body.error, body.message].find((v) => typeof v === "string" && v);
384
+ const err = /** @type {Error & { status?: number, body?: any }} */ (new Error(said || `HTTP ${response.status}`));
207
385
  err.status = response.status; // lets the :fetch lifecycle single out a 401 for token refresh
386
+ err.body = body ?? null;
208
387
  throw err;
209
388
  }
210
- try { return JSON.parse(text); } catch { return { status: response.status, body: text }; }
389
+ return body === undefined ? { status: response.status, body: text } : body;
390
+ }
391
+
392
+ /**
393
+ * Write the `<name>_error` / `<name>_error_body` pair for a key: the server's
394
+ * own message and its parsed JSON body, or null/null when there is no failure.
395
+ * @param {string} key
396
+ * @param {any} [error]
397
+ * @returns {void}
398
+ */
399
+ function setError(key, error) {
400
+ state[key + "_error"] = error ? error.message || String(error) : null;
401
+ state[key + "_error_body"] = (error && error.body) ?? null;
211
402
  }
212
403
 
213
- const computedDefs = [...document.querySelectorAll("[data-wd-computed]")].map((node) => ({
214
- key: node.getAttribute("data-wd-computed-key") || "",
215
- expr: node.getAttribute("data-wd-computed-expr") || "",
216
- ast: JSON.parse(node.getAttribute("data-wd-computed-expr") || "null")
217
- }));
404
+ /**
405
+ * Bump a node's generation token and hand back a staleness probe. An in-flight
406
+ * request that a newer one superseded must never write its result. Shared by the
407
+ * `:fetch` lifecycle and the round-trip `:form` submit.
408
+ * @param {{ __wdGen?: number }} node
409
+ * @returns {() => boolean}
410
+ */
411
+ function genGuard(node) {
412
+ const gen = (node.__wdGen = (node.__wdGen || 0) + 1);
413
+ return () => gen !== node.__wdGen;
414
+ }
415
+
416
+ /**
417
+ * Set whenever a render pass changes STRUCTURE — an `:if` branch injected, a loop
418
+ * row cloned, an `@empty` branch filled. It buys one more pass (the document-wide
419
+ * queries must reach the new nodes) and one more `scan()` (the new nodes may be
420
+ * directives no query had ever seen). True at boot: nothing is registered yet.
421
+ */
422
+ let dirty = true;
218
423
 
219
424
  /**
220
425
  * Log an expression failure when `window.wd.debug` is enabled.
@@ -228,16 +433,22 @@ function warn(expr, error) {
228
433
  }
229
434
 
230
435
  /**
231
- * Re-evaluate all `:computed` definitions into `state`.
436
+ * Re-evaluate every `:computed` marker in the live tree into `state`. Read from
437
+ * the DOM rather than a registry captured at load: `querySelectorAll` cannot see
438
+ * into `<template>` content, so a registry would permanently miss a `:computed`
439
+ * that only becomes live when an `:if` branch opens. `parseAst` caches by the raw
440
+ * attribute text, so re-reading costs an attribute read, not a re-parse.
232
441
  * @returns {void}
233
442
  */
234
443
  function recompute() {
235
- for (const def of computedDefs) {
444
+ for (const node of document.querySelectorAll("[data-wd-computed]")) {
445
+ const key = node.getAttribute("data-wd-computed-key") || "";
446
+ const expr = node.getAttribute("data-wd-computed-expr") || "null";
236
447
  try {
237
- state[def.key] = evalAst(def.ast, undefined);
448
+ state[key] = evalAst(parseAst(expr), undefined);
238
449
  } catch (error) {
239
- state[def.key] = undefined;
240
- warn(def.expr, error);
450
+ state[key] = undefined;
451
+ warn(expr, error);
241
452
  }
242
453
  }
243
454
  }
@@ -338,10 +549,13 @@ function classToggle(el, raw, item) {
338
549
  * @returns {string}
339
550
  */
340
551
  function loopKeyOf(item, counts) {
341
- const base =
552
+ // Every literal `#` doubles, so the `#n` duplicate suffix lives in a namespace
553
+ // no real key can reach: a genuine "a#1" keys as "a##1", never as a's duplicate.
554
+ const base = (
342
555
  item && typeof item === "object"
343
556
  ? String(item.id ?? item.key ?? JSON.stringify(item))
344
- : String(item);
557
+ : String(item)
558
+ ).replace(/#/g, "##");
345
559
  const seen = counts.get(base) || 0;
346
560
  counts.set(base, seen + 1);
347
561
  return seen ? `${base}#${seen}` : base;
@@ -359,8 +573,8 @@ function fillItem(node, item, meta = {}) {
359
573
  // Nested item-relative loops (data-wd-loop-item) own their descendants' binds;
360
574
  // when present, `skip` excludes a node living inside one so the outer row's
361
575
  // text/meta/each-if/class passes never overwrite an inner row's values.
362
- const inners = node.querySelectorAll("[data-wd-loop-item]");
363
- const skip = inners.length ? (/** @type {Element} */ el) => el.closest("[data-wd-loop-item]") : () => false;
576
+ let inners = node.querySelectorAll("[data-wd-loop-item]");
577
+ const skip = (/** @type {Element} */ el) => inners.length && el.closest("[data-wd-loop-item]");
364
578
  // querySelectorAll skips <template> content, so this sees only the outermost
365
579
  // regions; recursing into each injected branch fills the rest.
366
580
  for (const region of node.querySelectorAll("[data-wd-each-if]")) {
@@ -370,14 +584,17 @@ function fillItem(node, item, meta = {}) {
370
584
  const value = expr ? evalPredicate(parseAst(expr), item) : (m ? meta[m] : getPath(item, region.getAttribute("data-wd-path")));
371
585
  const output = region.querySelector("[data-wd-each-if-out]");
372
586
  if (!output) continue;
373
- const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]"));
374
- output.innerHTML = template?.innerHTML || "";
587
+ // swapBranch carries the same skip-when-unchanged guard the global :if has:
588
+ // re-injecting an unchanged branch on every render would destroy whatever is
589
+ // live inside it — a nested loop's reconciled rows, focus, a playing <video>.
590
+ swapBranch(region, output, value, "wd-if");
375
591
  fillItem(output, item, meta);
376
592
  }
377
- const targets = node.matches("[data-wd-each]")
378
- ? [node, ...node.querySelectorAll("[data-wd-each]")]
379
- : [...node.querySelectorAll("[data-wd-each]")];
380
- for (const target of targets) {
593
+ // An each-if branch can REVEAL a nested loop, and the capture above predates
594
+ // the injection — re-read it, or the passes below overwrite the inner rows
595
+ // with the OUTER row's values.
596
+ inners = node.querySelectorAll("[data-wd-loop-item]");
597
+ for (const target of selfAnd(node, "[data-wd-each]")) {
381
598
  if (skip(target)) continue;
382
599
  target.textContent = applyFmt(target, getPath(item, target.getAttribute("data-wd-path"))) ?? "";
383
600
  }
@@ -385,8 +602,10 @@ function fillItem(node, item, meta = {}) {
385
602
  if (skip(marker)) continue;
386
603
  marker.textContent = applyFmt(marker, meta[marker.getAttribute("data-wd-each-meta") || ""]) ?? "";
387
604
  }
388
- const classNodes = node.matches("[data-wd-each-class]") ? [node, ...node.querySelectorAll("[data-wd-each-class]")] : [...node.querySelectorAll("[data-wd-each-class]")];
389
- for (const el of classNodes) { if (!skip(el)) classToggle(el, el.getAttribute("data-wd-each-class"), item); }
605
+ for (const el of selfAnd(node, "[data-wd-each-class]")) { if (!skip(el)) classToggle(el, el.getAttribute("data-wd-each-class"), item); }
606
+ // Per-row bound attributes (a link/image destination reading the row). State
607
+ // parts resolve here too, so a destination mixing the two stays correct.
608
+ for (const el of selfAnd(node, "[data-wd-each-attr]")) { if (!skip(el)) applyAttr(el, el.getAttribute("data-wd-each-attr"), item, meta); }
390
609
  // Reconcile nested loops last, against the now-settled outer row.
391
610
  for (const region of inners) reconcile(region, getPath(item, region.getAttribute("data-wd-loop-item")));
392
611
  }
@@ -427,7 +646,7 @@ function pipeline(list, region) {
427
646
  const av = getPath(a.value, sort || null);
428
647
  const bv = getPath(b.value, sort || null);
429
648
  const c = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
430
- return (c || a.index - b.index) * dir;
649
+ return c ? c * dir : a.index - b.index;
431
650
  }).map((w) => w.value);
432
651
  }
433
652
  }
@@ -439,6 +658,30 @@ function pipeline(list, region) {
439
658
  return list;
440
659
  }
441
660
 
661
+ /**
662
+ * Swap a conditional region to the branch its truthiness selects, and report
663
+ * whether anything actually moved. The unchanged-guard is the load-bearing part:
664
+ * re-injecting the branch that is already showing would replace every live node
665
+ * inside it on every render. One implementation for both conditionals — the
666
+ * global `:if` (`prefix` "wd", templates `data-wd-true`/`-false`) and the per-row
667
+ * `:if` inside a loop row ("wd-if", `data-wd-if-true`/`-false`).
668
+ * @param {Element} region Carries data-wd-if-active, the last painted branch.
669
+ * @param {Element} output The node whose contents the branch replaces.
670
+ * @param {any} value
671
+ * @param {string} prefix
672
+ * @returns {boolean} true when the branch was swapped
673
+ */
674
+ function swapBranch(region, output, value, prefix) {
675
+ const active = String(Boolean(value));
676
+ if (region.getAttribute("data-wd-if-active") === active) return false;
677
+ region.setAttribute("data-wd-if-active", active);
678
+ const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector(`template[data-${prefix}-${active}]`));
679
+ unregister(output); // the branch being replaced takes its directives with it
680
+ output.innerHTML = template?.innerHTML || "";
681
+ // The branch may carry directives no query has ever seen.
682
+ return (dirty = true);
683
+ }
684
+
442
685
  /**
443
686
  * Evaluate one global `data-wd-if` region against current state and swap its
444
687
  * branch when the truthiness flips. `data-wd-if-expr` carries a compiled
@@ -454,13 +697,8 @@ function pipeline(list, region) {
454
697
  function renderIf(node) {
455
698
  const expr = node.getAttribute("data-wd-if-expr");
456
699
  const value = expr ? evalPredicate(parseAst(expr), undefined) : getPath(state[node.getAttribute("data-wd-if") || ""], node.getAttribute("data-wd-path"));
457
- const active = String(Boolean(value));
458
- if (node.getAttribute("data-wd-if-active") === active) return;
459
- node.setAttribute("data-wd-if-active", active);
460
700
  const output = node.querySelector("[data-wd-if-out]");
461
- if (!output) return;
462
- const template = /** @type {HTMLTemplateElement | null} */ (node.querySelector(value ? "template[data-wd-true]" : "template[data-wd-false]"));
463
- output.innerHTML = template?.innerHTML || "";
701
+ if (!output || !swapBranch(node, output, value, "wd")) return;
464
702
  for (const nested of output.querySelectorAll("[data-wd-if]")) renderIf(nested);
465
703
  }
466
704
 
@@ -489,13 +727,18 @@ function reconcile(region, rows) {
489
727
  const emptyTpl = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-empty]"));
490
728
  if (!list.length && emptyTpl) {
491
729
  if (out.getAttribute("data-wd-empty") !== "1") {
730
+ unregister(out); // the rows being wiped take their directives with them
492
731
  out.textContent = "";
493
732
  for (const child of [...emptyTpl.content.children]) out.appendChild(child.cloneNode(true));
494
733
  out.setAttribute("data-wd-empty", "1");
734
+ dirty = true;
495
735
  }
496
736
  return;
497
737
  }
498
- if (out.getAttribute("data-wd-empty") === "1") { out.textContent = ""; out.removeAttribute("data-wd-empty"); }
738
+ // The @empty branch is ONE branch, not a per-row clone, so it may legally hold
739
+ // an :every/:effect. Emptying and refilling the list re-clones it, so the copy
740
+ // leaving here must be dropped or the next empty state would tick twice.
741
+ if (out.getAttribute("data-wd-empty") === "1") { unregister(out); out.textContent = ""; out.removeAttribute("data-wd-empty"); }
499
742
 
500
743
  /** @type {Map<string, WdRow>} */
501
744
  const existing = new Map();
@@ -507,6 +750,7 @@ function reconcile(region, rows) {
507
750
  /** @type {Set<string>} */
508
751
  const used = new Set();
509
752
  const count = list.length;
753
+ let moved = false;
510
754
  for (let i = 0; i < count; i++) {
511
755
  const item = list[i];
512
756
  const key = loopKeyOf(item, counts);
@@ -515,59 +759,100 @@ function reconcile(region, rows) {
515
759
  node = /** @type {WdRow} */ (template.content.firstElementChild?.cloneNode(true));
516
760
  if (!node) continue;
517
761
  node.setAttribute("data-wd-loop-key", key);
762
+ // A fresh row missed this pass's document-wide if/class/scan work.
763
+ dirty = true;
518
764
  }
519
765
  used.add(key);
520
766
  fillItem(node, item, { index: i, number: i + 1, first: i === 0, last: i === count - 1, count });
521
767
  node.__wdItem = item; /** let per-row actions resolve which row was clicked */
522
- out.appendChild(node);
768
+ // Leave a row that is already in place ALONE. appendChild is a
769
+ // remove-then-insert per spec, so appending unconditionally blurs the
770
+ // focused field, resets scroll, restarts CSS transitions and reloads an
771
+ // <iframe> in a row — on every render, even when the list did not change.
772
+ // Once one row has had to move, every later row must be re-appended too, or
773
+ // the ones left behind would sit in front of it.
774
+ if (moved || out.children[i] !== node) { moved = true; out.appendChild(node); }
523
775
  }
524
776
  for (const [key, node] of existing) {
525
- if (!used.has(key)) node.remove();
777
+ if (!used.has(key)) { unregister(node); node.remove(); }
526
778
  }
527
779
  }
528
780
 
529
781
  /**
530
782
  * Synchronously render the whole document from current `state`:
531
- * computed → if-regions → keyed loop reconcile → text/input binds.
783
+ * (scan → computed → if-regions → reactive classes → keyed loop reconcile)
784
+ * repeated until the structure settles → text/input binds → theme → effects.
785
+ *
786
+ * The repeat is the point. The if/class passes are document-wide queries, so a
787
+ * row the loop reconcile CLONED during the same pass was not in the tree when
788
+ * they ran and would keep the compile-time branch and classes baked into its
789
+ * template — wrong until some unrelated state change. Symmetrically, a branch an
790
+ * `:if` just injected can carry `:fetch`/`:computed`/`:effect`/`:every`/`:theme`
791
+ * nodes that no query had ever seen. Either marks `dirty`, which buys one more
792
+ * pass; two passes always suffice in practice and three is the hard cap.
532
793
  * @returns {void}
533
794
  */
795
+ let settleTries = 0;
534
796
  function renderNow() {
535
- recompute();
536
- for (const node of document.querySelectorAll("[data-wd-if]")) renderIf(node);
797
+ for (let pass = 0; pass < 3; pass++) {
798
+ if (dirty) scan();
799
+ dirty = false;
800
+ recompute();
801
+ for (const node of document.querySelectorAll("[data-wd-if]")) renderIf(node);
537
802
 
538
- // Reactive styling: toggle state-driven `.class when …` bindings. Loop-row
539
- // bindings (data-wd-each-class) are handled per item inside fillItem.
540
- for (const el of document.querySelectorAll("[data-wd-class]")) classToggle(el, el.getAttribute("data-wd-class"), undefined);
803
+ // Reactive styling: toggle state-driven `.class when …` bindings. Loop-row
804
+ // bindings (data-wd-each-class) are handled per item inside fillItem.
805
+ for (const el of document.querySelectorAll("[data-wd-class]")) classToggle(el, el.getAttribute("data-wd-class"), undefined);
541
806
 
542
- // Nested item-relative loops use data-wd-loop-item, so this top-level query
543
- // skips them; fillItem reconciles those against their enclosing row.
544
- for (const region of document.querySelectorAll("[data-wd-loop]")) {
545
- const key = region.getAttribute("data-wd-loop");
546
- const data = region.getAttribute("data-wd-loop-data");
547
- /** Source may be a dotted path (e.g. team.members) read off state via getPath. */
548
- const dot = key ? key.indexOf(".") : -1;
549
- reconcile(region, key ? (dot < 0 ? state[key] : getPath(state[key.slice(0, dot)], key.slice(dot + 1))) : (data ? JSON.parse(data) : []));
807
+ // Nested item-relative loops use data-wd-loop-item, so this top-level query
808
+ // skips them; fillItem reconciles those against their enclosing row.
809
+ for (const region of document.querySelectorAll("[data-wd-loop]")) {
810
+ const key = region.getAttribute("data-wd-loop");
811
+ const data = region.getAttribute("data-wd-loop-data");
812
+ /** Source may be a dotted path (e.g. team.members) read off state via getPath. */
813
+ const dot = key ? key.indexOf(".") : -1;
814
+ reconcile(region, key ? (dot < 0 ? state[key] : getPath(state[key.slice(0, dot)], key.slice(dot + 1))) : (data ? JSON.parse(data) : []));
815
+ }
816
+ if (!dirty) break;
550
817
  }
818
+ // Deeper than the cap (an `:if` whose branch declares the `:computed` a nested
819
+ // `:if` reads, a few levels down) the loop used to exit still dirty and leave
820
+ // the innermost region showing its compile-time branch until some unrelated
821
+ // state change. Finish this paint, then schedule another pass; the counter
822
+ // bounds a genuine cycle instead of spinning forever.
823
+ if (dirty && ++settleTries < 10) {
824
+ if (/** @type {any} */ (window).wd?.debug) console.warn("[wd] render did not settle; scheduling another pass");
825
+ render();
826
+ } else settleTries = 0;
551
827
 
552
828
  for (const node of document.querySelectorAll("[data-wd-bind]")) {
553
829
  node.textContent = applyFmt(node, getPath(state[node.getAttribute("data-wd-bind") || ""], node.getAttribute("data-wd-path"))) ?? "";
554
830
  }
555
831
 
556
- for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
557
- if (document.activeElement !== input) /** @type {HTMLInputElement} */ (input).value = state[input.getAttribute("data-wd-bind-input") || ""] ?? "";
832
+ for (const node of document.querySelectorAll("[data-wd-attr]")) applyAttr(node, node.getAttribute("data-wd-attr"), undefined, {});
833
+
834
+ // Bound controls, reflected back from state. A checkbox carries a boolean and
835
+ // a radio carries whichever option equals the value, so neither can go through
836
+ // `.value`; a text field keeps the focus guard, because overwriting what
837
+ // someone is typing mid-keystroke moves their caret.
838
+ for (const input of /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll("[data-wd-bind-input]"))) {
839
+ const value = state[input.getAttribute("data-wd-bind-input") || ""] ?? "";
840
+ if (input.type === "checkbox") input.checked = Boolean(value);
841
+ else if (input.type === "radio") input.checked = input.value === String(value);
842
+ else if (document.activeElement !== input) input.value = value;
558
843
  }
559
844
 
560
- if (themeNodes.length) reflectThemes();
561
- runEffects(); // side effects run last, against fully settled state
845
+ reflectThemes();
846
+ // side effects run last, against fully settled state
847
+ runEffects();
562
848
  if (subscribers.size) notifySubscribers();
563
849
  }
564
850
 
565
851
  /** :theme — reflect a store value onto <html data-theme> so a manual light/dark
566
852
  * switch layers over the OS preference: `auto`/empty clears the attribute (follow
567
853
  * the system via the skin's `tokens dark` media query), anything else forces it. */
568
- const themeNodes = [...document.querySelectorAll("[data-wd-theme]")];
569
854
  function reflectThemes() {
570
- for (const node of themeNodes) {
855
+ for (const node of document.querySelectorAll("[data-wd-theme]")) {
571
856
  const value = state[node.getAttribute("data-wd-theme") || ""];
572
857
  const root = document.documentElement;
573
858
  if (value == null || value === "" || value === "auto") root.removeAttribute("data-theme");
@@ -575,12 +860,9 @@ function reflectThemes() {
575
860
  }
576
861
  }
577
862
 
578
- /** @typedef {{ watch: string, actions: { op: string, target?: string, value?: any }[], last: string }} Effect */
863
+ /** @typedef {{ node: Element, watch: string, actions: { op: string, target?: string, value?: any }[], last: string }} Effect */
579
864
  /** @type {Effect[]} */
580
- const effects = [...document.querySelectorAll("script[data-wd-effect]")].map((node) => {
581
- const def = JSON.parse(node.textContent || "{}");
582
- return { watch: def.watch, actions: def.actions || [], last: JSON.stringify(getPath(state, def.watch) ?? null) };
583
- });
865
+ const effects = [];
584
866
  let effectDepth = 0;
585
867
  /**
586
868
  * After a render, fire any `:effect` whose watched state changed, running its
@@ -595,10 +877,14 @@ function runEffects() {
595
877
  const now = JSON.stringify(getPath(state, fx.watch) ?? null);
596
878
  if (now === fx.last) continue;
597
879
  fx.last = now;
598
- for (const a of fx.actions) applyAction(null, a.op, a.target || "", a.value);
880
+ runActions(fx.actions);
599
881
  changed = true;
600
882
  }
601
883
  if (!changed) { effectDepth = 0; return; }
884
+ // An effect writes state exactly like a click does, so it persists and syncs
885
+ // to the URL exactly like a click does. Without this an `:effect` that writes
886
+ // a `from-url` key never reached the address bar at all.
887
+ savePersisted();
602
888
  if (++effectDepth >= 10) { effectDepth = 0; console.warn("effect did not settle"); return; }
603
889
  renderNow(); // reflect the effect's state changes, then re-check
604
890
  }
@@ -622,8 +908,9 @@ document.addEventListener("input", (event) => {
622
908
  const input = /** @type {HTMLInputElement | null} */ (/** @type {Element} */ (event.target)?.closest("[data-wd-bind-input]"));
623
909
  if (!input) return;
624
910
  // A `:slider` binds a range input: keep state numeric so `:computed`/math see a
625
- // number, not the DOM's string `.value`. Other inputs stay strings as before.
626
- state[input.getAttribute("data-wd-bind-input") || ""] = input.type === "range" ? input.valueAsNumber : input.value;
911
+ // number, not the DOM's string `.value`. A standalone `:checkbox` binds the
912
+ // boolean, not the label. Everything else stays a string, as before.
913
+ state[input.getAttribute("data-wd-bind-input") || ""] = input.type === "range" ? input.valueAsNumber : input.type === "checkbox" ? input.checked : input.value;
627
914
  savePersisted();
628
915
  render();
629
916
  checkRefetch();
@@ -687,6 +974,19 @@ function applyAction(action, op, target, value) {
687
974
  }
688
975
  }
689
976
 
977
+ /**
978
+ * Run a parsed action sequence with no clicked row — the `:effect` and `:every`
979
+ * paths. Their defs are parsed ONCE and re-fired, so an `append`/`prepend` would
980
+ * push the SAME object reference every time and a later `remove` (which filters
981
+ * by identity) would delete every copy at once. Clone per fire; the click path
982
+ * re-parses its JSON attribute each time and needs no copy.
983
+ * @param {{ op: string, target?: string, value?: any }[]} actions
984
+ * @returns {void}
985
+ */
986
+ const runActions = (actions) => {
987
+ for (const a of actions) applyAction(null, a.op, a.target || "", structuredClone(a.value));
988
+ };
989
+
690
990
  document.addEventListener("click", (event) => {
691
991
  const action = /** @type {Element} */ (event.target)?.closest("[data-wd-action],[data-wd-actions]");
692
992
  if (!action) return;
@@ -723,19 +1023,41 @@ document.addEventListener("submit", (event) => {
723
1023
  return;
724
1024
  }
725
1025
 
1026
+ // A slow earlier submit must not land on top of a newer one's reply, and the
1027
+ // previous attempt's error must not sit on screen through the retry.
1028
+ const dead = genGuard(/** @type {any} */ (form));
1029
+ setError(key);
1030
+ render();
1031
+ // ONE FormData feeds both bodies. Bound controls (`:bind`, a bound `:select`)
1032
+ // carry no `name`, so FormData never saw them: they are appended by their
1033
+ // state key here, before the split, so the urlencoded path carries exactly
1034
+ // the same keys the multipart path does. A bound key that a real field
1035
+ // already occupies is left alone — sending both would hand the server the
1036
+ // same name twice with different values.
1037
+ const fd = new FormData(form);
1038
+ for (const el of form.querySelectorAll("[data-wd-bind-input]")) {
1039
+ const bound = el.getAttribute("data-wd-bind-input");
1040
+ if (bound && !el.getAttribute("name") && !fd.has(bound)) fd.append(bound, state[bound] ?? "");
1041
+ }
1042
+ // A file cannot be urlencoded, so a form carrying one posts the FormData
1043
+ // itself and lets the browser set the multipart content-type WITH its
1044
+ // boundary, which is exactly why no content-type is passed here.
1045
+ const upload = form.querySelector("input[type=file]");
726
1046
  httpJson(action, {
727
1047
  method: form.getAttribute("method") || "post",
728
- headers: { "content-type": "application/x-www-form-urlencoded" },
729
- body: new URLSearchParams(/** @type {any} */ (new FormData(form))).toString()
1048
+ headers: upload ? undefined : { "content-type": "application/x-www-form-urlencoded" },
1049
+ body: upload ? fd : new URLSearchParams(/** @type {any} */ (fd)).toString()
730
1050
  })
731
1051
  .then((value) => {
1052
+ if (dead()) return;
732
1053
  state[key] = value;
733
- state[`${key}_error`] = null;
1054
+ setError(key);
734
1055
  savePersisted();
735
1056
  render();
736
1057
  })
737
1058
  .catch((error) => {
738
- state[`${key}_error`] = String(error);
1059
+ if (dead()) return;
1060
+ setError(key, error);
739
1061
  render();
740
1062
  });
741
1063
  });
@@ -749,9 +1071,7 @@ document.addEventListener("submit", (event) => {
749
1071
  */
750
1072
  function startFetch(node) {
751
1073
  // Per-node generation token: a refetch bumps it; superseded writes bail.
752
- const gen = node.__wdGen = (node.__wdGen || 0) + 1;
753
- /** @returns {boolean} stale once a newer fetch superseded this one */
754
- const dead = () => gen !== node.__wdGen;
1074
+ const dead = genGuard(node);
755
1075
  /** @param {string} n @returns {string} */
756
1076
  const g = (n) => node.getAttribute("data-wd-fetch-" + n) || "";
757
1077
  const key = g("key");
@@ -772,11 +1092,11 @@ function startFetch(node) {
772
1092
  const timeout = Number(g("timeout"));
773
1093
  let tries = Number(g("retry"));
774
1094
  let refreshed = false; // a token refresh + retry happens at most once per request
775
- state[key + "_error"] = null;
1095
+ setError(key);
776
1096
  set("_loading", true);
777
1097
 
778
1098
  /** @param {any} error */
779
- const fail = (error) => { state[key + "_error"] = String(error); set("_loading", false); };
1099
+ const fail = (error) => { setError(key, error); set("_loading", false); };
780
1100
 
781
1101
  const attempt = () => {
782
1102
  /** @type {RequestInit} */
@@ -791,7 +1111,7 @@ function startFetch(node) {
791
1111
  if (dead()) return;
792
1112
  state[key] = value;
793
1113
  state[key + "_empty"] = isEmpty(value);
794
- state[key + "_error"] = null;
1114
+ setError(key);
795
1115
  set("_loading", false);
796
1116
  }).catch((error) => {
797
1117
  clearTimeout(timer);
@@ -847,70 +1167,127 @@ function refreshToken(refreshUrl, headersKey) {
847
1167
  * a generation token so a superseded in-flight response/retry bails on write. */
848
1168
  /** @typedef {Element & { __wdSnap?: string, __wdGen?: number }} FetchNode */
849
1169
  /** @type {FetchNode[]} */
850
- const fetchNodes = [...document.querySelectorAll("[data-wd-fetch]")];
1170
+ const fetchNodes = [];
851
1171
  /** @param {FetchNode} node @returns {string} */
852
1172
  const depSnapshot = (node) => (node.getAttribute("data-wd-fetch-deps") || "").split(",").filter(Boolean).map((d) => JSON.stringify(getPath(state, d))).join("|");
853
1173
 
854
1174
  /** @type {any} */
855
1175
  let refetchTimer = 0;
1176
+ /** @type {Set<FetchNode>} Nodes owed a refetch, accumulated across the window. */
1177
+ const due = new Set();
856
1178
  /**
857
1179
  * After a mutation render, debounce-refetch any fetch node whose URL deps
858
- * changed. Snapshots live on the node, so no extra bookkeeping structure.
1180
+ * changed. Snapshots live on the node, so no extra bookkeeping structure. The
1181
+ * due set OUTLIVES each debounce restart: a second mutation resets the timer,
1182
+ * and a node dropped there could never be re-detected — its snapshot has already
1183
+ * been advanced — so its refetch would be lost rather than merely delayed.
859
1184
  * @returns {void}
860
1185
  */
861
1186
  function checkRefetch() {
862
- /** @type {FetchNode[]} */
863
- const due = [];
864
1187
  for (const node of fetchNodes) {
865
1188
  const snap = depSnapshot(node);
866
- if (snap && snap !== node.__wdSnap) { node.__wdSnap = snap; due.push(node); }
1189
+ if (snap && snap !== node.__wdSnap) { node.__wdSnap = snap; due.add(node); }
867
1190
  }
868
- if (!due.length) return;
1191
+ if (!due.size) return;
869
1192
  clearTimeout(refetchTimer);
870
- refetchTimer = setTimeout(() => { for (const node of due) startFetch(node); }, 150);
1193
+ refetchTimer = setTimeout(() => { for (const node of due) startFetch(node); due.clear(); }, 150);
871
1194
  }
872
1195
 
873
- for (const node of fetchNodes) {
874
- node.__wdSnap = depSnapshot(node);
875
- if (node.getAttribute("data-wd-fetch-when") === "visible" && "IntersectionObserver" in window) {
876
- const observer = new IntersectionObserver((entries) => {
877
- if (!entries.some((entry) => entry.isIntersecting)) return;
878
- observer.disconnect();
879
- startFetch(node);
880
- });
881
- observer.observe(node);
882
- } else {
883
- startFetch(node);
1196
+ /**
1197
+ * Drop every registration belonging to a subtree that is about to leave the
1198
+ * document — a closed `:if` branch, a removed loop row.
1199
+ *
1200
+ * `scan()` claims a node once, and the claim marker lives ON the node while the
1201
+ * registries (`effects`, `fetchNodes`, `everyDefs`) are module-level. A branch
1202
+ * is re-cloned from its template every time it opens, so without this the NEXT
1203
+ * open registers a FRESH node while the detached one stays: an `:every` ticked
1204
+ * twice after one reopen, three times after two, and kept ticking with the
1205
+ * branch closed. Same shape for `:effect` (fired once per stale copy) and
1206
+ * `:fetch` (one request per stale copy, on a state change the reader cannot
1207
+ * even see).
1208
+ * @param {Element} root
1209
+ * @returns {void}
1210
+ */
1211
+ function unregister(root) {
1212
+ for (const node of selfAnd(root, "[data-wd-fetch],[data-wd-effect],[data-wd-every]")) {
1213
+ due.delete(/** @type {any} */ (node));
1214
+ // A fetch entry IS the node; an effect/every entry carries it on `.node`.
1215
+ for (const list of [/** @type {any[]} */ (fetchNodes), effects, everyDefs]) {
1216
+ for (let i = list.length - 1; i >= 0; i--) {
1217
+ if ((list[i].node || list[i]) !== node) continue;
1218
+ if (list[i].t) clearInterval(list[i].t);
1219
+ list.splice(i, 1);
1220
+ }
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ /**
1226
+ * Claim and wire up the directives that need ONE-TIME setup: `:fetch` (kick off
1227
+ * the request, snapshot its deps), `:effect` (parse the def, snapshot its watch)
1228
+ * and `:every` (start a timer). `querySelectorAll` cannot see into `<template>`
1229
+ * content, so building these at load permanently missed any that lived in an
1230
+ * unopened `:if` branch or a loop row template — they simply never ran. Called
1231
+ * again whenever a render changes structure; each node is claimed once
1232
+ * (`__wdReg`), so a re-scan costs the query and nothing else. (`:computed` and
1233
+ * `:theme` need no claim at all: both read the live tree on every render.)
1234
+ * @returns {void}
1235
+ */
1236
+ function scan() {
1237
+ // Seed scripts first: a branch that just opened may DECLARE the state the
1238
+ // directives below read (see hydrate).
1239
+ hydrate();
1240
+ for (const node of /** @type {any} */ (document.querySelectorAll("[data-wd-fetch],[data-wd-effect],[data-wd-every]"))) {
1241
+ if (node.__wdReg) continue;
1242
+ node.__wdReg = 1;
1243
+ if (node.hasAttribute("data-wd-effect")) {
1244
+ const def = JSON.parse(node.textContent || "{}");
1245
+ effects.push({ node, watch: def.watch, actions: def.actions || [], last: JSON.stringify(getPath(state, def.watch) ?? null) });
1246
+ }
1247
+ if (node.hasAttribute("data-wd-every")) {
1248
+ if (!everyDefs.length) document.addEventListener("visibilitychange", () => (document.hidden ? stopEvery() : startEvery()));
1249
+ everyDefs.push({ ...JSON.parse(node.textContent || "{}"), node });
1250
+ startEvery();
1251
+ }
1252
+ if (node.hasAttribute("data-wd-fetch")) {
1253
+ fetchNodes.push(node);
1254
+ node.__wdSnap = depSnapshot(node);
1255
+ if (node.getAttribute("data-wd-fetch-when") === "visible" && "IntersectionObserver" in window) {
1256
+ const observer = new IntersectionObserver((entries) => {
1257
+ if (!entries.some((entry) => entry.isIntersecting)) return;
1258
+ observer.disconnect();
1259
+ startFetch(node);
1260
+ });
1261
+ observer.observe(node);
1262
+ } else {
1263
+ startFetch(node);
1264
+ }
1265
+ }
884
1266
  }
885
1267
  }
886
1268
 
887
1269
  // :every — run actions on a timer, paused while the tab is hidden so background
888
1270
  // tabs don't poll or animate. Each tick runs the action sequence (no clicked row,
889
1271
  // like :effect), then saves + renders + checks refetch deps, exactly like a click.
890
- /** @typedef {{ ms: number, actions: { op: string, target?: string, value?: any }[] }} EveryDef */
1272
+ /** @typedef {{ node: Element, ms: number, actions: { op: string, target?: string, value?: any }[], t?: any }} EveryDef */
891
1273
  /** @type {EveryDef[]} */
892
- const everyDefs = [...document.querySelectorAll("script[data-wd-every]")].map((node) => JSON.parse(node.textContent || "{}"));
893
- /** @type {any[]} */
894
- let everyTimers = [];
1274
+ const everyDefs = [];
1275
+ // The timer lives on the def, not in a shared list, so a def registered later
1276
+ // (one revealed by an :if flip) starts ticking without restarting the others.
895
1277
  function startEvery() {
896
- if (everyTimers.length || document.hidden) return;
1278
+ if (document.hidden) return;
897
1279
  for (const def of everyDefs) {
898
- if (!def.ms || !def.actions) continue;
899
- everyTimers.push(setInterval(() => {
900
- for (const a of def.actions) applyAction(null, a.op, a.target || "", a.value);
1280
+ if (def.t || !def.ms || !def.actions) continue;
1281
+ def.t = setInterval(() => {
1282
+ runActions(def.actions);
901
1283
  savePersisted();
902
1284
  render();
903
1285
  checkRefetch();
904
- }, def.ms));
1286
+ }, def.ms);
905
1287
  }
906
1288
  }
907
1289
  function stopEvery() {
908
- for (const timer of everyTimers) clearInterval(timer);
909
- everyTimers = [];
910
- }
911
- if (everyDefs.length) {
912
- document.addEventListener("visibilitychange", () => (document.hidden ? stopEvery() : startEvery()));
913
- startEvery();
1290
+ for (const def of everyDefs) { clearInterval(def.t); def.t = 0; }
914
1291
  }
915
1292
 
916
1293
  // Escape-hatch subscriptions: colocated `.js` "behaviors" (carousels, drag-and-
@@ -944,6 +1321,8 @@ function notifySubscribers() {
944
1321
  state[key] = value;
945
1322
  savePersisted();
946
1323
  render();
1324
+ // The input and click handlers both do; behaviors write only through here.
1325
+ checkRefetch();
947
1326
  },
948
1327
  /**
949
1328
  * Run `cb(value)` now and on every future change to `state[key]`. Returns an