@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.
- package/AGENTS.md +392 -77
- package/CHANGELOG.md +47 -0
- package/README.md +316 -49
- package/docs/cli.md +51 -17
- package/docs/spec-alignment.md +25 -4
- package/grammar/wd-directives.gbnf +13 -5
- package/package.json +7 -3
- package/site/_/base.skin +1 -1
- package/site/_/footer.wd +1 -1
- package/site/pages/api-errors/index.wd +81 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/attr-binding/index.wd +84 -0
- package/site/pages/blog/darkmown-2-7-0.md +176 -0
- package/site/pages/blog/darkmown-2-7-0.skin +127 -0
- package/site/pages/blog/no-unsafe-eval.md +66 -0
- package/site/pages/blog/no-unsafe-eval.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/branch-state/index.wd +68 -0
- package/site/pages/docs/index.wd +320 -19
- package/site/pages/index.wd +3 -1
- package/site/pages/playground/index.js +63 -56
- package/site/pages/pulse/index.wd +1 -1
- package/site/pages/settings/index.wd +97 -0
- package/site/pages/showcase/index.wd +37 -1
- package/site/pages/upload/index.wd +63 -0
- package/site/pages/url-state/index.wd +70 -0
- package/src/builder.js +222 -34
- package/src/catalog.js +217 -21
- package/src/cli.js +6 -3
- package/src/compiler/actions.js +80 -12
- package/src/compiler/body.js +104 -28
- package/src/compiler/collections.js +29 -13
- package/src/compiler/context.js +9 -2
- package/src/compiler/expr-ast.js +14 -0
- package/src/compiler/fetch.js +8 -4
- package/src/compiler/forms.js +133 -14
- package/src/compiler/frontmatter.js +28 -7
- package/src/compiler/includes.js +46 -6
- package/src/compiler/interpolation.js +56 -1
- package/src/compiler/loops.js +489 -35
- package/src/compiler/markdown.js +457 -44
- package/src/compiler/page.js +8 -3
- package/src/compiler/predicates.js +84 -14
- package/src/compiler/state.js +167 -18
- package/src/compiler/structure.js +159 -29
- package/src/errors.js +111 -3
- package/src/feeds.js +140 -7
- package/src/grammar.js +53 -10
- package/src/runtime.js +509 -130
- package/src/runtime.min.js +2 -0
- package/src/runtime.min.js.map +1 -0
- package/src/statics.js +46 -2
- package/src/templates/blog/README.md +2 -0
- package/src/templates/dashboard/README.md +2 -0
- package/src/templates/landing/README.md +2 -0
- package/src/templates/starter/README.md +1 -1
- package/src/templates/store/README.md +2 -0
- package/src/tools/validate.js +14 -4
- package/types/builder.d.ts +6 -2
- package/types/catalog.d.ts +12 -0
- package/types/compiler/actions.d.ts +1 -1
- package/types/compiler/body.d.ts +0 -3
- package/types/compiler/forms.d.ts +12 -1
- package/types/compiler/frontmatter.d.ts +8 -1
- package/types/compiler/includes.d.ts +16 -2
- package/types/compiler/interpolation.d.ts +23 -0
- package/types/compiler/loops.d.ts +47 -0
- package/types/compiler/markdown.d.ts +63 -1
- package/types/compiler/predicates.d.ts +7 -1
- package/types/compiler/state.d.ts +8 -2
- package/types/compiler/structure.d.ts +32 -1
- package/types/feeds.d.ts +68 -2
- 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
|
-
|
|
21
|
-
|
|
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
|
-
//
|
|
32
|
-
// deep-clones the
|
|
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 =
|
|
26
|
+
const initials = {};
|
|
35
27
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
} catch {
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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)
|
|
53
|
-
for (const name of storeKeys)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
|
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[
|
|
448
|
+
state[key] = evalAst(parseAst(expr), undefined);
|
|
238
449
|
} catch (error) {
|
|
239
|
-
state[
|
|
240
|
-
warn(
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
const skip =
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
|
389
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
|
557
|
-
|
|
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
|
-
|
|
561
|
-
|
|
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
|
|
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 = [
|
|
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
|
-
|
|
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`.
|
|
626
|
-
|
|
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} */ (
|
|
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
|
-
|
|
1054
|
+
setError(key);
|
|
734
1055
|
savePersisted();
|
|
735
1056
|
render();
|
|
736
1057
|
})
|
|
737
1058
|
.catch((error) => {
|
|
738
|
-
|
|
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
|
|
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
|
-
|
|
1095
|
+
setError(key);
|
|
776
1096
|
set("_loading", true);
|
|
777
1097
|
|
|
778
1098
|
/** @param {any} error */
|
|
779
|
-
const fail = (error) => {
|
|
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
|
-
|
|
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 = [
|
|
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.
|
|
1189
|
+
if (snap && snap !== node.__wdSnap) { node.__wdSnap = snap; due.add(node); }
|
|
867
1190
|
}
|
|
868
|
-
if (!due.
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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 = [
|
|
893
|
-
|
|
894
|
-
|
|
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 (
|
|
1278
|
+
if (document.hidden) return;
|
|
897
1279
|
for (const def of everyDefs) {
|
|
898
|
-
if (!def.ms || !def.actions) continue;
|
|
899
|
-
|
|
900
|
-
|
|
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
|
|
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
|