@zvndev/darkmown 0.9.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/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
- localStorage.setItem(`wd:${key}`, JSON.stringify(state[key] ?? null));
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 === "constructor" || segment === "prototype" || segment === "__proto__") return undefined;
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 text binds.
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 does not descend into <template> content, so this only sees
132
- // the outermost regions; recursing into each injected branch fills the rest.
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 value = getPath(item, region.getAttribute("data-wd-path"));
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
- const rows = key ? state[key] : (data ? JSON.parse(data) : []);
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
- for (const item of list) {
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
- // Coalesce rapid state mutations into one render on the next tick. Each scheduled
225
- // pass always reads the latest state, so the final update is never dropped.
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
- document.addEventListener("click", (event) => {
259
- const action = /** @type {Element} */ (event.target)?.closest("[data-wd-action]");
260
- if (!action) return;
261
- const op = action.getAttribute("data-wd-action");
262
- const target = action.getAttribute("data-wd-target") || "";
263
- const rawValue = action.getAttribute("data-wd-value");
264
- const value = rawValue === null ? undefined : JSON.parse(rawValue);
265
-
266
- if (op === "inc") state[target] = Number(state[target] ?? 0) + 1;
267
- if (op === "dec") state[target] = Number(state[target] ?? 0) - 1;
268
- if (op === "add") state[target] = Number(state[target] ?? 0) + Number(value);
269
- if (op === "append") state[target] = [...(Array.isArray(state[target]) ? state[target] : []), value];
270
- if (op === "set") state[target] = value;
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 the row so each appended line is a distinct object — otherwise adding
278
- // the same source row twice yields two identical references and a later
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
- state[target] = [...(Array.isArray(state[target]) ? state[target] : []), copy];
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
- fetch(action, {
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(async (response) => {
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
- * Kick off a `:fetch` request and write the result (or error) into state.
330
- * @param {Element} node
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
- const key = node.getAttribute("data-wd-fetch-key") || "";
335
- fetch(node.getAttribute("data-wd-fetch-url") || "")
336
- .then((response) => {
337
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
338
- return response.json();
339
- })
340
- .then((value) => {
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[`${key}_error`] = null;
343
- render();
344
- })
345
- .catch((error) => {
346
- state[`${key}_error`] = String(error);
347
- render();
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
- for (const node of document.querySelectorAll("[data-wd-fetch]")) {
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
- // Public escape hatch on `window.wd`. Set `window.wd.debug = true` to log failing
365
- // computed/where expressions to the console.
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/src/scaffold.js CHANGED
@@ -13,9 +13,12 @@ const darkmownVersion = JSON.parse(
13
13
  export function initProject(root) {
14
14
  fs.mkdirSync(root, { recursive: true });
15
15
  writeNew(root, "package.json", JSON.stringify({
16
+ name: packageNameFromRoot(root),
17
+ private: true,
16
18
  scripts: {
17
19
  dev: "darkmown dev",
18
- build: "darkmown build"
20
+ build: "darkmown build",
21
+ preview: "darkmown serve"
19
22
  },
20
23
  devDependencies: {
21
24
  "@zvndev/darkmown": `^${darkmownVersion}`
@@ -146,3 +149,19 @@ function writeNew(root, file, content) {
146
149
  fs.mkdirSync(path.dirname(target), { recursive: true });
147
150
  fs.writeFileSync(target, `${content}\n`);
148
151
  }
152
+
153
+
154
+ /**
155
+ * Derive a safe default npm package name from the scaffold target directory.
156
+ * @param {string} root Absolute target directory.
157
+ * @returns {string}
158
+ */
159
+ function packageNameFromRoot(root) {
160
+ const base = path.basename(root) || "darkmown-site";
161
+ const normalized = base
162
+ .toLowerCase()
163
+ .replace(/^@/, "")
164
+ .replace(/[^a-z0-9._~-]+/g, "-")
165
+ .replace(/^[._-]+|[._-]+$/g, "");
166
+ return normalized || "darkmown-site";
167
+ }
@@ -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
- * A parsed `:button` action.
64
+ * Loop clause configuration shared by the static and reactive paths.
55
65
  */
56
- export type Action = {
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
- * Runtime operation (inc/dec/add/append/set/remove/append-row).
76
+ * Empty-branch body lines, if any.
59
77
  */
60
- op: string;
78
+ empty: string[] | null;
61
79
  /**
62
- * State key the action mutates.
80
+ * Whether offset/limit reference state.
63
81
  */
64
- target: string;
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";