@zvndev/darkmown 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +213 -9
- package/docs/spec-alignment.md +10 -2
- package/package.json +1 -1
- package/site/_/nav.wd +2 -0
- package/site/pages/docs/index.wd +100 -4
- package/site/pages/loops.skin +80 -0
- package/site/pages/loops.wd +86 -0
- package/site/pages/store.skin +70 -0
- package/site/pages/store.wd +53 -0
- package/src/builder.js +14 -9
- package/src/compiler.js +494 -72
- package/src/runtime.js +294 -62
- package/types/compiler.d.ts +49 -10
package/src/runtime.js
CHANGED
|
@@ -14,12 +14,24 @@
|
|
|
14
14
|
const state = {};
|
|
15
15
|
/** @type {Set<string>} */
|
|
16
16
|
const persistKeys = new Set();
|
|
17
|
+
/** @type {Set<string>} Non-ephemeral :store names, persisted under wd:store:<name>. */
|
|
18
|
+
const storeKeys = new Set();
|
|
17
19
|
|
|
18
20
|
for (const script of document.querySelectorAll("script[data-wd-state]")) {
|
|
19
21
|
Object.assign(state, JSON.parse(script.textContent || "{}"));
|
|
20
22
|
const persist = script.getAttribute("data-wd-persist");
|
|
21
23
|
if (persist) persistKeys.add(persist);
|
|
22
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
|
+
}
|
|
30
|
+
|
|
31
|
+
// Frozen seed snapshot, taken BEFORE localStorage overrides below, so `reset`
|
|
32
|
+
// deep-clones the declared value — not the last persisted one.
|
|
33
|
+
/** @type {Record<string, any>} */
|
|
34
|
+
const initials = Object.freeze(JSON.parse(JSON.stringify(state)));
|
|
23
35
|
|
|
24
36
|
for (const key of persistKeys) {
|
|
25
37
|
const stored = localStorage.getItem(`wd:${key}`);
|
|
@@ -30,13 +42,35 @@ for (const key of persistKeys) {
|
|
|
30
42
|
localStorage.removeItem(`wd:${key}`);
|
|
31
43
|
}
|
|
32
44
|
}
|
|
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 */ }
|
|
49
|
+
}
|
|
33
50
|
|
|
34
51
|
function savePersisted() {
|
|
35
|
-
for (const key of persistKeys) {
|
|
36
|
-
|
|
37
|
-
}
|
|
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));
|
|
38
54
|
}
|
|
39
55
|
|
|
56
|
+
window.addEventListener("storage", (event) => {
|
|
57
|
+
const name = event.key && event.key.startsWith("wd:store:") ? event.key.slice(9) : "";
|
|
58
|
+
if (!storeKeys.has(name) || event.newValue == null) return;
|
|
59
|
+
let next;
|
|
60
|
+
try { next = JSON.parse(event.newValue); } catch { return; }
|
|
61
|
+
if (JSON.stringify(next) === JSON.stringify(state[name])) return;
|
|
62
|
+
state[name] = next;
|
|
63
|
+
render();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* True for prototype-pollution path segments that must never be read or written.
|
|
68
|
+
* Single source of truth for the rejected-key list (used by getPath + setPath).
|
|
69
|
+
* @param {string} segment
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
const unsafeKey = (segment) => segment === "__proto__" || segment === "constructor" || segment === "prototype";
|
|
73
|
+
|
|
40
74
|
/**
|
|
41
75
|
* Safely read a dotted path off a value, rejecting prototype-pollution segments.
|
|
42
76
|
* @param {any} value
|
|
@@ -48,12 +82,59 @@ function getPath(value, path) {
|
|
|
48
82
|
let current = value;
|
|
49
83
|
for (const segment of path.split(".")) {
|
|
50
84
|
if (current == null) return undefined;
|
|
51
|
-
if (segment
|
|
85
|
+
if (unsafeKey(segment)) return undefined;
|
|
52
86
|
current = current[segment];
|
|
53
87
|
}
|
|
54
88
|
return current;
|
|
55
89
|
}
|
|
56
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Safely write a value at a dotted path, creating plain objects for missing
|
|
93
|
+
* intermediates and rejecting prototype-pollution segments at every level.
|
|
94
|
+
* @param {any} obj
|
|
95
|
+
* @param {string} path
|
|
96
|
+
* @param {any} value
|
|
97
|
+
* @returns {void}
|
|
98
|
+
*/
|
|
99
|
+
function setPath(obj, path, value) {
|
|
100
|
+
const segs = path.split(".");
|
|
101
|
+
const last = segs.pop() || "";
|
|
102
|
+
let cur = obj;
|
|
103
|
+
for (const seg of segs) {
|
|
104
|
+
if (unsafeKey(seg)) return;
|
|
105
|
+
if (cur[seg] == null || typeof cur[seg] !== "object") cur[seg] = {};
|
|
106
|
+
cur = cur[seg];
|
|
107
|
+
}
|
|
108
|
+
if (unsafeKey(last)) return;
|
|
109
|
+
cur[last] = value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Empty for fetch purposes: null/undefined, an empty array, or an object with
|
|
114
|
+
* no own keys. Drives the `name_empty` lifecycle flag.
|
|
115
|
+
* @param {any} v
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
function isEmpty(v) {
|
|
119
|
+
return v == null || (typeof v === "object" && (Array.isArray(v) ? v.length : Object.keys(v).length) === 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Shared HTTP→JSON core for `:fetch` and the round-trip `:form`. Sends the
|
|
124
|
+
* request, throws on non-2xx, and parses the body as JSON (falling back to a
|
|
125
|
+
* `{status,body}` wrapper for non-JSON responses). Unifying both callers keeps
|
|
126
|
+
* the runtime under budget.
|
|
127
|
+
* @param {string} url
|
|
128
|
+
* @param {RequestInit} [init]
|
|
129
|
+
* @returns {Promise<any>}
|
|
130
|
+
*/
|
|
131
|
+
async function httpJson(url, init) {
|
|
132
|
+
const response = await fetch(url, init);
|
|
133
|
+
const text = await response.text();
|
|
134
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
135
|
+
try { return JSON.parse(text); } catch { return { status: response.status, body: text }; }
|
|
136
|
+
}
|
|
137
|
+
|
|
57
138
|
const computedDefs = [...document.querySelectorAll("[data-wd-computed]")].map((node) => ({
|
|
58
139
|
key: node.getAttribute("data-wd-computed-key") || "",
|
|
59
140
|
expr: node.getAttribute("data-wd-computed-expr") || "",
|
|
@@ -122,21 +203,24 @@ function loopKeyOf(item, counts) {
|
|
|
122
203
|
}
|
|
123
204
|
|
|
124
205
|
/**
|
|
125
|
-
* Fill a cloned loop row node: resolve per-item `:if` regions and
|
|
206
|
+
* Fill a cloned loop row node: resolve per-item `:if` regions (item- and
|
|
207
|
+
* meta-driven), text binds, and per-row meta markers.
|
|
126
208
|
* @param {Element} node
|
|
127
209
|
* @param {any} item
|
|
210
|
+
* @param {Record<string, any>} [meta] Per-row meta values (index/number/…).
|
|
128
211
|
* @returns {void}
|
|
129
212
|
*/
|
|
130
|
-
function fillItem(node, item) {
|
|
131
|
-
// querySelectorAll
|
|
132
|
-
//
|
|
213
|
+
function fillItem(node, item, meta = {}) {
|
|
214
|
+
// querySelectorAll skips <template> content, so this sees only the outermost
|
|
215
|
+
// regions; recursing into each injected branch fills the rest.
|
|
133
216
|
for (const region of node.querySelectorAll("[data-wd-each-if]")) {
|
|
134
|
-
const
|
|
217
|
+
const m = region.getAttribute("data-wd-meta");
|
|
218
|
+
const value = m ? meta[m] : getPath(item, region.getAttribute("data-wd-path"));
|
|
135
219
|
const output = region.querySelector("[data-wd-each-if-out]");
|
|
136
220
|
if (!output) continue;
|
|
137
221
|
const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]"));
|
|
138
222
|
output.innerHTML = template?.innerHTML || "";
|
|
139
|
-
fillItem(output, item);
|
|
223
|
+
fillItem(output, item, meta);
|
|
140
224
|
}
|
|
141
225
|
const targets = node.matches("[data-wd-each]")
|
|
142
226
|
? [node, ...node.querySelectorAll("[data-wd-each]")]
|
|
@@ -144,6 +228,48 @@ function fillItem(node, item) {
|
|
|
144
228
|
for (const target of targets) {
|
|
145
229
|
target.textContent = getPath(item, target.getAttribute("data-wd-path")) ?? "";
|
|
146
230
|
}
|
|
231
|
+
for (const marker of node.querySelectorAll("[data-wd-each-meta]")) {
|
|
232
|
+
marker.textContent = meta[marker.getAttribute("data-wd-each-meta") || ""] ?? "";
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Read a loop offset/limit attribute that is a literal int or a `key:<name>`
|
|
238
|
+
* referencing state. Returns null when the attribute is absent.
|
|
239
|
+
* @param {Element} region
|
|
240
|
+
* @param {string} name
|
|
241
|
+
* @returns {number | null}
|
|
242
|
+
*/
|
|
243
|
+
function loopNum(region, name) {
|
|
244
|
+
const raw = region.getAttribute(name);
|
|
245
|
+
if (raw == null) return null;
|
|
246
|
+
return raw.startsWith("key:") ? Number(state[raw.slice(4)] ?? 0) : Number(raw);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Apply sort → reverse → offset → limit to an already-filtered list, reading
|
|
251
|
+
* clause config off the region. Stable; numeric vs localeCompare comparator.
|
|
252
|
+
* @param {any[]} list
|
|
253
|
+
* @param {Element} region
|
|
254
|
+
* @returns {any[]}
|
|
255
|
+
*/
|
|
256
|
+
function pipeline(list, region) {
|
|
257
|
+
const sort = region.getAttribute("data-wd-loop-sort");
|
|
258
|
+
if (sort != null) {
|
|
259
|
+
const dir = region.getAttribute("data-wd-loop-sort-dir") === "desc" ? -1 : 1;
|
|
260
|
+
list = list.map((value, index) => ({ value, index })).sort((a, b) => {
|
|
261
|
+
const av = getPath(a.value, sort || null);
|
|
262
|
+
const bv = getPath(b.value, sort || null);
|
|
263
|
+
const c = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
|
|
264
|
+
return (c || a.index - b.index) * dir;
|
|
265
|
+
}).map((w) => w.value);
|
|
266
|
+
}
|
|
267
|
+
if (region.hasAttribute("data-wd-loop-reverse")) list.reverse();
|
|
268
|
+
const off = loopNum(region, "data-wd-loop-offset");
|
|
269
|
+
if (off) list = list.slice(off);
|
|
270
|
+
const lim = loopNum(region, "data-wd-loop-limit");
|
|
271
|
+
if (lim != null) list = list.slice(0, lim);
|
|
272
|
+
return list;
|
|
147
273
|
}
|
|
148
274
|
|
|
149
275
|
/**
|
|
@@ -167,11 +293,13 @@ function renderNow() {
|
|
|
167
293
|
for (const region of document.querySelectorAll("[data-wd-loop]")) {
|
|
168
294
|
const key = region.getAttribute("data-wd-loop");
|
|
169
295
|
const data = region.getAttribute("data-wd-loop-data");
|
|
170
|
-
|
|
296
|
+
/** Source may be a dotted path (e.g. team.members) read off state via getPath. */
|
|
297
|
+
const dot = key ? key.indexOf(".") : -1;
|
|
298
|
+
const rows = key ? (dot < 0 ? state[key] : getPath(state[key.slice(0, dot)], key.slice(dot + 1))) : (data ? JSON.parse(data) : []);
|
|
171
299
|
const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-template]"));
|
|
172
300
|
const out = region.querySelector("[data-wd-loop-out]");
|
|
173
301
|
if (!template || !out) continue;
|
|
174
|
-
let list = Array.isArray(rows) ? rows : [];
|
|
302
|
+
let list = Array.isArray(rows) ? rows.slice() : [];
|
|
175
303
|
const where = region.getAttribute("data-wd-loop-where");
|
|
176
304
|
if (where) {
|
|
177
305
|
const predicate = loopPredicate(where);
|
|
@@ -184,6 +312,19 @@ function renderNow() {
|
|
|
184
312
|
}
|
|
185
313
|
});
|
|
186
314
|
}
|
|
315
|
+
list = pipeline(list, region);
|
|
316
|
+
|
|
317
|
+
// Empty branch: clone the [data-wd-loop-empty] template into the output.
|
|
318
|
+
const emptyTpl = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-empty]"));
|
|
319
|
+
if (!list.length && emptyTpl) {
|
|
320
|
+
if (out.getAttribute("data-wd-empty") !== "1") {
|
|
321
|
+
out.textContent = "";
|
|
322
|
+
for (const child of [...emptyTpl.content.children]) out.appendChild(child.cloneNode(true));
|
|
323
|
+
out.setAttribute("data-wd-empty", "1");
|
|
324
|
+
}
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (out.getAttribute("data-wd-empty") === "1") { out.textContent = ""; out.removeAttribute("data-wd-empty"); }
|
|
187
328
|
|
|
188
329
|
/** @type {Map<string, WdRow>} */
|
|
189
330
|
const existing = new Map();
|
|
@@ -194,7 +335,9 @@ function renderNow() {
|
|
|
194
335
|
const counts = new Map();
|
|
195
336
|
/** @type {Set<string>} */
|
|
196
337
|
const used = new Set();
|
|
197
|
-
|
|
338
|
+
const count = list.length;
|
|
339
|
+
for (let i = 0; i < count; i++) {
|
|
340
|
+
const item = list[i];
|
|
198
341
|
const key = loopKeyOf(item, counts);
|
|
199
342
|
let node = existing.get(key);
|
|
200
343
|
if (!node || used.has(key)) {
|
|
@@ -203,7 +346,7 @@ function renderNow() {
|
|
|
203
346
|
node.setAttribute("data-wd-loop-key", key);
|
|
204
347
|
}
|
|
205
348
|
used.add(key);
|
|
206
|
-
fillItem(node, item);
|
|
349
|
+
fillItem(node, item, { index: i, number: i + 1, first: i === 0, last: i === count - 1, count });
|
|
207
350
|
node.__wdItem = item; // let per-row actions resolve which row was clicked
|
|
208
351
|
out.appendChild(node);
|
|
209
352
|
}
|
|
@@ -221,8 +364,10 @@ function renderNow() {
|
|
|
221
364
|
}
|
|
222
365
|
}
|
|
223
366
|
|
|
224
|
-
|
|
225
|
-
|
|
367
|
+
/**
|
|
368
|
+
* Coalesce rapid state mutations into one render on the next tick. Each scheduled
|
|
369
|
+
* pass always reads the latest state, so the final update is never dropped.
|
|
370
|
+
*/
|
|
226
371
|
const schedule = typeof requestAnimationFrame === "function" ? requestAnimationFrame : queueMicrotask;
|
|
227
372
|
let scheduled = false;
|
|
228
373
|
function render() {
|
|
@@ -240,11 +385,12 @@ document.addEventListener("input", (event) => {
|
|
|
240
385
|
state[input.getAttribute("data-wd-bind-input") || ""] = input.value;
|
|
241
386
|
savePersisted();
|
|
242
387
|
render();
|
|
388
|
+
checkRefetch();
|
|
243
389
|
});
|
|
244
390
|
|
|
245
|
-
// The clicked button's row: the nearest reconciled loop node carries its item.
|
|
246
391
|
/**
|
|
247
392
|
* Resolve the loop source and row item for a clicked element, if inside a loop.
|
|
393
|
+
* The clicked button's row is the nearest reconciled loop node; it carries its item.
|
|
248
394
|
* @param {Element} el
|
|
249
395
|
* @returns {{ srcKey: string | null, item: any } | null}
|
|
250
396
|
*/
|
|
@@ -255,35 +401,63 @@ function clickedRow(el) {
|
|
|
255
401
|
return { srcKey: region.getAttribute("data-wd-loop"), item: row.__wdItem };
|
|
256
402
|
}
|
|
257
403
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
404
|
+
/**
|
|
405
|
+
* Apply one parsed action `{op,target,value}` against state. Targets are dotted
|
|
406
|
+
* paths read via getPath / written via setPath; a bare name is a 1-segment path.
|
|
407
|
+
* @param {Element} action The clicked element (for loop-row resolution).
|
|
408
|
+
* @param {string} op
|
|
409
|
+
* @param {string} target
|
|
410
|
+
* @param {any} value
|
|
411
|
+
* @returns {void}
|
|
412
|
+
*/
|
|
413
|
+
function applyAction(action, op, target, value) {
|
|
414
|
+
/** @param {any} v */
|
|
415
|
+
const put = (v) => setPath(state, target, v);
|
|
416
|
+
const cur = getPath(state, target);
|
|
417
|
+
const arr = () => (Array.isArray(cur) ? cur : []);
|
|
418
|
+
if (op === "inc") put(Number(cur ?? 0) + 1);
|
|
419
|
+
if (op === "dec") put(Number(cur ?? 0) - 1);
|
|
420
|
+
if (op === "add") put(Number(cur ?? 0) + Number(value));
|
|
421
|
+
if (op === "sub") put(Number(cur ?? 0) - Number(value));
|
|
422
|
+
if (op === "toggle") put(!cur);
|
|
423
|
+
if (op === "set") put(value);
|
|
424
|
+
if (op === "append") put([...arr(), value]);
|
|
425
|
+
if (op === "prepend") put([value, ...arr()]);
|
|
426
|
+
if (op === "member-toggle") put(arr().includes(value) ? arr().filter((/** @type {any} */ x) => x !== value) : [...arr(), value]);
|
|
427
|
+
if (op === "remove-value") put(arr().filter((/** @type {any} */ x) => x !== value));
|
|
428
|
+
if (op === "clear") put(Array.isArray(cur) ? [] : {});
|
|
429
|
+
if (op === "merge") put({ ...(cur && typeof cur === "object" ? cur : {}), ...(typeof value === "string" ? getPath(state, value) : value) });
|
|
430
|
+
if (op === "delete") { if (cur && typeof cur === "object") { delete cur[value]; put(cur); } }
|
|
431
|
+
if (op === "reset") put(structuredClone(getPath(initials, target)));
|
|
432
|
+
if (op === "refetch") { const n = document.querySelector(`[data-wd-fetch-key="${target}"]`); if (n) startFetch(n); }
|
|
271
433
|
if (op === "remove") {
|
|
272
434
|
const row = clickedRow(action);
|
|
273
435
|
if (row && row.srcKey) state[row.srcKey] = (Array.isArray(state[row.srcKey]) ? state[row.srcKey] : []).filter((/** @type {any} */ x) => x !== row.item);
|
|
274
436
|
}
|
|
275
437
|
if (op === "append-row") {
|
|
276
438
|
const row = clickedRow(action);
|
|
277
|
-
// Clone
|
|
278
|
-
//
|
|
279
|
-
// remove (filter by !== ref) would delete both lines.
|
|
439
|
+
// Clone so each appended line is a distinct object — else a shared ref
|
|
440
|
+
// means a later remove (filter !== ref) deletes both lines.
|
|
280
441
|
if (row && row.item !== undefined) {
|
|
281
442
|
const copy = row.item && typeof row.item === "object" ? structuredClone(row.item) : row.item;
|
|
282
|
-
|
|
443
|
+
put([...arr(), copy]);
|
|
283
444
|
}
|
|
284
445
|
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
document.addEventListener("click", (event) => {
|
|
449
|
+
const action = /** @type {Element} */ (event.target)?.closest("[data-wd-action],[data-wd-actions]");
|
|
450
|
+
if (!action) return;
|
|
451
|
+
const seq = action.getAttribute("data-wd-actions");
|
|
452
|
+
if (seq) {
|
|
453
|
+
for (const a of JSON.parse(seq)) applyAction(action, a.op, a.target || "", a.value);
|
|
454
|
+
} else {
|
|
455
|
+
const rawValue = action.getAttribute("data-wd-value");
|
|
456
|
+
applyAction(action, action.getAttribute("data-wd-action") || "", action.getAttribute("data-wd-target") || "", rawValue === null ? undefined : JSON.parse(rawValue));
|
|
457
|
+
}
|
|
285
458
|
savePersisted();
|
|
286
459
|
render();
|
|
460
|
+
checkRefetch();
|
|
287
461
|
});
|
|
288
462
|
|
|
289
463
|
document.addEventListener("submit", (event) => {
|
|
@@ -300,20 +474,12 @@ document.addEventListener("submit", (event) => {
|
|
|
300
474
|
return;
|
|
301
475
|
}
|
|
302
476
|
|
|
303
|
-
|
|
477
|
+
httpJson(action, {
|
|
304
478
|
method: form.getAttribute("method") || "post",
|
|
305
479
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
306
480
|
body: new URLSearchParams(/** @type {any} */ (new FormData(form))).toString()
|
|
307
481
|
})
|
|
308
|
-
.then(
|
|
309
|
-
const text = await response.text();
|
|
310
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
311
|
-
let value;
|
|
312
|
-
try {
|
|
313
|
-
value = JSON.parse(text);
|
|
314
|
-
} catch {
|
|
315
|
-
value = { status: response.status, body: text };
|
|
316
|
-
}
|
|
482
|
+
.then((value) => {
|
|
317
483
|
state[key] = value;
|
|
318
484
|
state[`${key}_error`] = null;
|
|
319
485
|
savePersisted();
|
|
@@ -326,29 +492,93 @@ document.addEventListener("submit", (event) => {
|
|
|
326
492
|
});
|
|
327
493
|
|
|
328
494
|
/**
|
|
329
|
-
*
|
|
330
|
-
*
|
|
495
|
+
* Run the full `:fetch` lifecycle for a marker node: interpolate the URL from
|
|
496
|
+
* state, skip when a dependency is empty, flip `*_loading`, fetch with an
|
|
497
|
+
* AbortController timeout + flat retry, then write value/`*_empty` or `*_error`.
|
|
498
|
+
* @param {FetchNode} node
|
|
331
499
|
* @returns {void}
|
|
332
500
|
*/
|
|
333
501
|
function startFetch(node) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
502
|
+
// Per-node generation token: a refetch bumps it; superseded writes bail.
|
|
503
|
+
const gen = node.__wdGen = (node.__wdGen || 0) + 1;
|
|
504
|
+
/** @returns {boolean} stale once a newer fetch superseded this one */
|
|
505
|
+
const dead = () => gen !== node.__wdGen;
|
|
506
|
+
/** @param {string} n @returns {string} */
|
|
507
|
+
const g = (n) => node.getAttribute("data-wd-fetch-" + n) || "";
|
|
508
|
+
const key = g("key");
|
|
509
|
+
/** @param {string} s @param {any} v */
|
|
510
|
+
const set = (s, v) => { state[key + s] = v; render(); };
|
|
511
|
+
let missing = false;
|
|
512
|
+
const url = g("url").replace(/\{\s*([\w$.]+)\s*\}/g, (_, p) => {
|
|
513
|
+
const v = getPath(state, p);
|
|
514
|
+
if (v == null || v === "") missing = true;
|
|
515
|
+
return encodeURIComponent(String(v ?? ""));
|
|
516
|
+
});
|
|
517
|
+
if (missing) return set("_loading", false);
|
|
518
|
+
|
|
519
|
+
const headers = g("headers");
|
|
520
|
+
const body = g("body");
|
|
521
|
+
const method = g("method") || "GET";
|
|
522
|
+
const timeout = Number(g("timeout"));
|
|
523
|
+
let tries = Number(g("retry"));
|
|
524
|
+
state[key + "_error"] = null;
|
|
525
|
+
set("_loading", true);
|
|
526
|
+
|
|
527
|
+
const attempt = () => {
|
|
528
|
+
/** @type {RequestInit} */
|
|
529
|
+
const init = { method };
|
|
530
|
+
if (headers) init.headers = state[headers] || {};
|
|
531
|
+
if (body && method !== "GET") init.body = JSON.stringify(state[body] ?? null);
|
|
532
|
+
const ctrl = typeof AbortController === "function" ? new AbortController() : null;
|
|
533
|
+
if (ctrl) init.signal = ctrl.signal;
|
|
534
|
+
const timer = timeout && ctrl ? setTimeout(() => ctrl.abort(), timeout) : 0;
|
|
535
|
+
httpJson(url, init).then((value) => {
|
|
536
|
+
clearTimeout(timer);
|
|
537
|
+
if (dead()) return;
|
|
341
538
|
state[key] = value;
|
|
342
|
-
state[
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
.catch((error) => {
|
|
346
|
-
|
|
347
|
-
|
|
539
|
+
state[key + "_empty"] = isEmpty(value);
|
|
540
|
+
state[key + "_error"] = null;
|
|
541
|
+
set("_loading", false);
|
|
542
|
+
}).catch((error) => {
|
|
543
|
+
clearTimeout(timer);
|
|
544
|
+
if (dead()) return;
|
|
545
|
+
if (tries-- > 0) return void setTimeout(attempt, 200);
|
|
546
|
+
state[key + "_error"] = String(error);
|
|
547
|
+
set("_loading", false);
|
|
348
548
|
});
|
|
549
|
+
};
|
|
550
|
+
attempt();
|
|
349
551
|
}
|
|
350
552
|
|
|
351
|
-
|
|
553
|
+
/** A fetch marker carrying its last dependency snapshot for refetch diffing and
|
|
554
|
+
* a generation token so a superseded in-flight response/retry bails on write. */
|
|
555
|
+
/** @typedef {Element & { __wdSnap?: string, __wdGen?: number }} FetchNode */
|
|
556
|
+
/** @type {FetchNode[]} */
|
|
557
|
+
const fetchNodes = [...document.querySelectorAll("[data-wd-fetch]")];
|
|
558
|
+
/** @param {FetchNode} node @returns {string} */
|
|
559
|
+
const depSnapshot = (node) => (node.getAttribute("data-wd-fetch-deps") || "").split(",").filter(Boolean).map((d) => JSON.stringify(getPath(state, d))).join("|");
|
|
560
|
+
|
|
561
|
+
/** @type {any} */
|
|
562
|
+
let refetchTimer = 0;
|
|
563
|
+
/**
|
|
564
|
+
* After a mutation render, debounce-refetch any fetch node whose URL deps
|
|
565
|
+
* changed. Snapshots live on the node, so no extra bookkeeping structure.
|
|
566
|
+
* @returns {void}
|
|
567
|
+
*/
|
|
568
|
+
function checkRefetch() {
|
|
569
|
+
/** @type {FetchNode[]} */
|
|
570
|
+
const due = [];
|
|
571
|
+
for (const node of fetchNodes) {
|
|
572
|
+
const snap = depSnapshot(node);
|
|
573
|
+
if (snap && snap !== node.__wdSnap) { node.__wdSnap = snap; due.push(node); }
|
|
574
|
+
}
|
|
575
|
+
if (!due.length) return;
|
|
576
|
+
clearTimeout(refetchTimer);
|
|
577
|
+
refetchTimer = setTimeout(() => { for (const node of due) startFetch(node); }, 150);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
for (const node of fetchNodes) {
|
|
581
|
+
node.__wdSnap = depSnapshot(node);
|
|
352
582
|
if (node.getAttribute("data-wd-fetch-when") === "visible" && "IntersectionObserver" in window) {
|
|
353
583
|
const observer = new IntersectionObserver((entries) => {
|
|
354
584
|
if (!entries.some((entry) => entry.isIntersecting)) return;
|
|
@@ -361,8 +591,10 @@ for (const node of document.querySelectorAll("[data-wd-fetch]")) {
|
|
|
361
591
|
}
|
|
362
592
|
}
|
|
363
593
|
|
|
364
|
-
|
|
365
|
-
|
|
594
|
+
/**
|
|
595
|
+
* Public escape hatch on `window.wd`. Set `window.wd.debug = true` to log failing
|
|
596
|
+
* computed/where expressions to the console.
|
|
597
|
+
*/
|
|
366
598
|
/** @type {any} */ (window).wd = {
|
|
367
599
|
state,
|
|
368
600
|
debug: false,
|
package/types/compiler.d.ts
CHANGED
|
@@ -37,6 +37,16 @@ export function loopKeyOf(item: unknown, counts: Map<string, number>): string;
|
|
|
37
37
|
* @returns {string}
|
|
38
38
|
*/
|
|
39
39
|
export function escapeHtml(value: unknown): string;
|
|
40
|
+
/**
|
|
41
|
+
* A loop offset/limit argument: a non-negative integer literal or a state key.
|
|
42
|
+
*/
|
|
43
|
+
export type NumArg = {
|
|
44
|
+
kind: "literal";
|
|
45
|
+
value: number;
|
|
46
|
+
} | {
|
|
47
|
+
kind: "key";
|
|
48
|
+
value: string;
|
|
49
|
+
};
|
|
40
50
|
/**
|
|
41
51
|
* A compiled `@loop … where` predicate.
|
|
42
52
|
*/
|
|
@@ -51,21 +61,25 @@ export type Predicate = {
|
|
|
51
61
|
refsState: boolean;
|
|
52
62
|
};
|
|
53
63
|
/**
|
|
54
|
-
*
|
|
64
|
+
* Loop clause configuration shared by the static and reactive paths.
|
|
55
65
|
*/
|
|
56
|
-
export type
|
|
66
|
+
export type LoopOpts = {
|
|
67
|
+
where: Predicate | null;
|
|
68
|
+
sort: {
|
|
69
|
+
key: string;
|
|
70
|
+
dir: string;
|
|
71
|
+
} | null;
|
|
72
|
+
reverse: boolean;
|
|
73
|
+
offset: NumArg | null;
|
|
74
|
+
limit: NumArg | null;
|
|
57
75
|
/**
|
|
58
|
-
*
|
|
76
|
+
* Empty-branch body lines, if any.
|
|
59
77
|
*/
|
|
60
|
-
|
|
78
|
+
empty: string[] | null;
|
|
61
79
|
/**
|
|
62
|
-
*
|
|
80
|
+
* Whether offset/limit reference state.
|
|
63
81
|
*/
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Literal value for value-carrying ops.
|
|
67
|
-
*/
|
|
68
|
-
value?: unknown;
|
|
82
|
+
clauseRefsState: boolean;
|
|
69
83
|
};
|
|
70
84
|
export type Paths = import("./config.js").Paths;
|
|
71
85
|
/**
|
|
@@ -106,6 +120,10 @@ export type Compilation = {
|
|
|
106
120
|
* Declared state keys → initial values.
|
|
107
121
|
*/
|
|
108
122
|
state: Map<string, unknown>;
|
|
123
|
+
/**
|
|
124
|
+
* Page-global store names (a subset of state keys).
|
|
125
|
+
*/
|
|
126
|
+
stores: Set<string>;
|
|
109
127
|
/**
|
|
110
128
|
* Non-fatal authoring hints.
|
|
111
129
|
*/
|
|
@@ -158,6 +176,10 @@ export type Ctx = {
|
|
|
158
176
|
* State key of the list being looped, if any.
|
|
159
177
|
*/
|
|
160
178
|
loopKey?: string | undefined;
|
|
179
|
+
/**
|
|
180
|
+
* Inside a loop body, so `$index`/`$first`/… are valid.
|
|
181
|
+
*/
|
|
182
|
+
loopMeta?: boolean | undefined;
|
|
161
183
|
/**
|
|
162
184
|
* Markdown-it instance selected for this file.
|
|
163
185
|
*/
|
|
@@ -181,4 +203,21 @@ export type CompiledPage = {
|
|
|
181
203
|
assets: Assets;
|
|
182
204
|
warnings: string[];
|
|
183
205
|
};
|
|
206
|
+
/**
|
|
207
|
+
* A parsed `:button` action.
|
|
208
|
+
*/
|
|
209
|
+
export type Action = {
|
|
210
|
+
/**
|
|
211
|
+
* Runtime operation (inc/dec/add/sub/append/prepend/set/toggle/member-toggle/remove/remove-value/clear/merge/delete/reset/append-row).
|
|
212
|
+
*/
|
|
213
|
+
op: string;
|
|
214
|
+
/**
|
|
215
|
+
* State key (possibly dotted) the action mutates.
|
|
216
|
+
*/
|
|
217
|
+
target: string;
|
|
218
|
+
/**
|
|
219
|
+
* Literal value for value-carrying ops.
|
|
220
|
+
*/
|
|
221
|
+
value?: unknown;
|
|
222
|
+
};
|
|
184
223
|
import MarkdownIt from "markdown-it";
|