@zvndev/darkmown 0.12.1 → 0.14.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
@@ -131,7 +131,11 @@ function isEmpty(v) {
131
131
  async function httpJson(url, init) {
132
132
  const response = await fetch(url, init);
133
133
  const text = await response.text();
134
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
134
+ if (!response.ok) {
135
+ const err = /** @type {Error & { status?: number }} */ (new Error(`HTTP ${response.status}`));
136
+ err.status = response.status; // lets the :fetch lifecycle single out a 401 for token refresh
137
+ throw err;
138
+ }
135
139
  try { return JSON.parse(text); } catch { return { status: response.status, body: text }; }
136
140
  }
137
141
 
@@ -186,6 +190,37 @@ function loopPredicate(body) {
186
190
  return fn;
187
191
  }
188
192
 
193
+ /**
194
+ * Toggle reactive `.class when <predicate>` bindings on an element. `raw` is the
195
+ * JSON `[[name, body], …]` from data-wd-class (global, item undefined) or
196
+ * data-wd-each-class (per loop row); each body is the same I()/S()/C()
197
+ * expression the loop `where` uses.
198
+ * @param {Element} el
199
+ * @param {string | null} raw
200
+ * @param {any} item Loop item for each-class; undefined for a global class.
201
+ * @returns {void}
202
+ */
203
+ function classToggle(el, raw, item) {
204
+ if (!raw) return;
205
+ /** @type {[string, string][]} */
206
+ let pairs;
207
+ try { pairs = JSON.parse(raw); } catch { return; }
208
+ for (const [name, body] of pairs) el.classList.toggle(name, evalPredicate(body, item));
209
+ }
210
+
211
+ /**
212
+ * Evaluate a compiled whitelist predicate body (over I()/S()/C()) to a boolean.
213
+ * Shared by reactive classes (`.class when`) and expression conditionals
214
+ * (`:if a > b`). `item` is the loop row for per-row predicates, else undefined.
215
+ * @param {string} body
216
+ * @param {any} item
217
+ * @returns {boolean}
218
+ */
219
+ function evalPredicate(body, item) {
220
+ try { return Boolean(loopPredicate(body)((/** @type {string} */ p) => getPath(item, p), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn)); }
221
+ catch (error) { warn(body, error); return false; }
222
+ }
223
+
189
224
  /**
190
225
  * Stable per-render key for a loop row, disambiguating duplicates with `#n`.
191
226
  * @param {any} item
@@ -214,8 +249,9 @@ function fillItem(node, item, meta = {}) {
214
249
  // querySelectorAll skips <template> content, so this sees only the outermost
215
250
  // regions; recursing into each injected branch fills the rest.
216
251
  for (const region of node.querySelectorAll("[data-wd-each-if]")) {
252
+ const expr = region.getAttribute("data-wd-if-expr");
217
253
  const m = region.getAttribute("data-wd-meta");
218
- const value = m ? meta[m] : getPath(item, region.getAttribute("data-wd-path"));
254
+ const value = expr ? evalPredicate(expr, item) : (m ? meta[m] : getPath(item, region.getAttribute("data-wd-path")));
219
255
  const output = region.querySelector("[data-wd-each-if-out]");
220
256
  if (!output) continue;
221
257
  const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]"));
@@ -231,6 +267,8 @@ function fillItem(node, item, meta = {}) {
231
267
  for (const marker of node.querySelectorAll("[data-wd-each-meta]")) {
232
268
  marker.textContent = meta[marker.getAttribute("data-wd-each-meta") || ""] ?? "";
233
269
  }
270
+ const classNodes = node.matches("[data-wd-each-class]") ? [node, ...node.querySelectorAll("[data-wd-each-class]")] : [...node.querySelectorAll("[data-wd-each-class]")];
271
+ for (const el of classNodes) classToggle(el, el.getAttribute("data-wd-each-class"), item);
234
272
  }
235
273
 
236
274
  /**
@@ -272,6 +310,31 @@ function pipeline(list, region) {
272
310
  return list;
273
311
  }
274
312
 
313
+ /**
314
+ * Evaluate one global `data-wd-if` region against current state and swap its
315
+ * branch when the truthiness flips. `data-wd-if-expr` carries a compiled
316
+ * predicate (richer `:if`); otherwise it is a bare-path truthiness test. On a
317
+ * swap, the freshly injected branch can carry nested if-regions (a desugared
318
+ * `:else if`) that were never in the live tree this pass — querySelectorAll
319
+ * skips <template> content — so recurse to fill them. Unchanged regions are
320
+ * left alone; their already-live nested regions are reached by renderNow's
321
+ * document-wide query.
322
+ * @param {Element} node
323
+ * @returns {void}
324
+ */
325
+ function renderIf(node) {
326
+ const expr = node.getAttribute("data-wd-if-expr");
327
+ const value = expr ? evalPredicate(expr, undefined) : getPath(state[node.getAttribute("data-wd-if") || ""], node.getAttribute("data-wd-path"));
328
+ const active = String(Boolean(value));
329
+ if (node.getAttribute("data-wd-if-active") === active) return;
330
+ node.setAttribute("data-wd-if-active", active);
331
+ const output = node.querySelector("[data-wd-if-out]");
332
+ if (!output) return;
333
+ const template = /** @type {HTMLTemplateElement | null} */ (node.querySelector(value ? "template[data-wd-true]" : "template[data-wd-false]"));
334
+ output.innerHTML = template?.innerHTML || "";
335
+ for (const nested of output.querySelectorAll("[data-wd-if]")) renderIf(nested);
336
+ }
337
+
275
338
  /**
276
339
  * Synchronously render the whole document from current `state`:
277
340
  * computed → if-regions → keyed loop reconcile → text/input binds.
@@ -279,16 +342,11 @@ function pipeline(list, region) {
279
342
  */
280
343
  function renderNow() {
281
344
  recompute();
282
- for (const node of document.querySelectorAll("[data-wd-if]")) {
283
- const value = getPath(state[node.getAttribute("data-wd-if") || ""], node.getAttribute("data-wd-path"));
284
- const active = String(Boolean(value));
285
- if (node.getAttribute("data-wd-if-active") === active) continue;
286
- node.setAttribute("data-wd-if-active", active);
287
- const output = node.querySelector("[data-wd-if-out]");
288
- if (!output) continue;
289
- const template = /** @type {HTMLTemplateElement | null} */ (node.querySelector(value ? "template[data-wd-true]" : "template[data-wd-false]"));
290
- output.innerHTML = template?.innerHTML || "";
291
- }
345
+ for (const node of document.querySelectorAll("[data-wd-if]")) renderIf(node);
346
+
347
+ // Reactive styling: toggle state-driven `.class when …` bindings. Loop-row
348
+ // bindings (data-wd-each-class) are handled per item inside fillItem.
349
+ for (const el of document.querySelectorAll("[data-wd-class]")) classToggle(el, el.getAttribute("data-wd-class"), undefined);
292
350
 
293
351
  for (const region of document.querySelectorAll("[data-wd-loop]")) {
294
352
  const key = region.getAttribute("data-wd-loop");
@@ -362,6 +420,36 @@ function renderNow() {
362
420
  for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
363
421
  if (document.activeElement !== input) /** @type {HTMLInputElement} */ (input).value = state[input.getAttribute("data-wd-bind-input") || ""] ?? "";
364
422
  }
423
+
424
+ runEffects(); // side effects run last, against fully settled state
425
+ }
426
+
427
+ /** @typedef {{ watch: string, actions: { op: string, target?: string, value?: any }[], last: string }} Effect */
428
+ /** @type {Effect[]} */
429
+ const effects = [...document.querySelectorAll("script[data-wd-effect]")].map((node) => {
430
+ const def = JSON.parse(node.textContent || "{}");
431
+ return { watch: def.watch, actions: def.actions || [], last: JSON.stringify(getPath(state, def.watch) ?? null) };
432
+ });
433
+ let effectDepth = 0;
434
+ /**
435
+ * After a render, fire any `:effect` whose watched state changed, running its
436
+ * actions through the shared action runner. If that mutates state, re-render and
437
+ * re-check; a settle cap of 10 passes guards against effect→effect loops.
438
+ * @returns {void}
439
+ */
440
+ function runEffects() {
441
+ if (!effects.length) return;
442
+ let changed = false;
443
+ for (const fx of effects) {
444
+ const now = JSON.stringify(getPath(state, fx.watch) ?? null);
445
+ if (now === fx.last) continue;
446
+ fx.last = now;
447
+ for (const a of fx.actions) applyAction(null, a.op, a.target || "", a.value);
448
+ changed = true;
449
+ }
450
+ if (!changed) { effectDepth = 0; return; }
451
+ if (++effectDepth >= 10) { effectDepth = 0; console.warn("effect did not settle"); return; }
452
+ renderNow(); // reflect the effect's state changes, then re-check
365
453
  }
366
454
 
367
455
  /**
@@ -391,10 +479,11 @@ document.addEventListener("input", (event) => {
391
479
  /**
392
480
  * Resolve the loop source and row item for a clicked element, if inside a loop.
393
481
  * The clicked button's row is the nearest reconciled loop node; it carries its item.
394
- * @param {Element} el
482
+ * @param {Element | null} el Null for effect-driven actions (no clicked row).
395
483
  * @returns {{ srcKey: string | null, item: any } | null}
396
484
  */
397
485
  function clickedRow(el) {
486
+ if (!el) return null; // effect-driven actions have no clicked row → row ops no-op
398
487
  const row = /** @type {WdRow | null} */ (el.closest("[data-wd-loop-key]"));
399
488
  const region = el.closest("[data-wd-loop]");
400
489
  if (!row || !region) return null;
@@ -404,7 +493,7 @@ function clickedRow(el) {
404
493
  /**
405
494
  * Apply one parsed action `{op,target,value}` against state. Targets are dotted
406
495
  * 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).
496
+ * @param {Element | null} action The clicked element (for loop-row resolution); null for effects.
408
497
  * @param {string} op
409
498
  * @param {string} target
410
499
  * @param {any} value
@@ -518,12 +607,17 @@ function startFetch(node) {
518
607
 
519
608
  const headers = g("headers");
520
609
  const body = g("body");
610
+ const refresh = g("refresh");
521
611
  const method = g("method") || "GET";
522
612
  const timeout = Number(g("timeout"));
523
613
  let tries = Number(g("retry"));
614
+ let refreshed = false; // a token refresh + retry happens at most once per request
524
615
  state[key + "_error"] = null;
525
616
  set("_loading", true);
526
617
 
618
+ /** @param {any} error */
619
+ const fail = (error) => { state[key + "_error"] = String(error); set("_loading", false); };
620
+
527
621
  const attempt = () => {
528
622
  /** @type {RequestInit} */
529
623
  const init = { method };
@@ -542,14 +636,53 @@ function startFetch(node) {
542
636
  }).catch((error) => {
543
637
  clearTimeout(timer);
544
638
  if (dead()) return;
639
+ const is401 = error && error.status === 401;
640
+ // Layer 2: a 401 with a refresh URL renews the token once, then retries.
641
+ if (is401 && refresh && headers && !refreshed) {
642
+ refreshed = true;
643
+ return void refreshToken(refresh, headers).then((ok) => {
644
+ if (dead()) return;
645
+ ok ? attempt() : fail(error);
646
+ });
647
+ }
648
+ if (is401 && refreshed) return void fail(error); // a second 401 after refresh → give up
545
649
  if (tries-- > 0) return void setTimeout(attempt, 200);
546
- state[key + "_error"] = String(error);
547
- set("_loading", false);
650
+ fail(error);
548
651
  });
549
652
  };
550
653
  attempt();
551
654
  }
552
655
 
656
+ /** @type {Map<string, Promise<boolean>>} In-flight token refreshes, keyed by URL. */
657
+ const refreshFlights = new Map();
658
+ /**
659
+ * Single-flight token refresh: POST the current header state to `refreshUrl`,
660
+ * and on a 2xx JSON reply write the returned headers object back into the
661
+ * `headersKey` state (persisting if it is a `:store`). Concurrent 401s sharing a
662
+ * refresh URL await the same request, then each retries its own fetch.
663
+ * @param {string} refreshUrl
664
+ * @param {string} headersKey
665
+ * @returns {Promise<boolean>} whether the token was renewed
666
+ */
667
+ function refreshToken(refreshUrl, headersKey) {
668
+ const inflight = refreshFlights.get(refreshUrl);
669
+ if (inflight) return inflight;
670
+ const flight = httpJson(refreshUrl, {
671
+ method: "POST",
672
+ headers: { "content-type": "application/json" },
673
+ body: JSON.stringify(state[headersKey] ?? {})
674
+ }).then((value) => {
675
+ if (value && typeof value === "object") {
676
+ state[headersKey] = value; // the new headers shape, e.g. { Authorization: "Bearer …" }
677
+ savePersisted();
678
+ return true;
679
+ }
680
+ return false;
681
+ }).catch(() => false).finally(() => refreshFlights.delete(refreshUrl));
682
+ refreshFlights.set(refreshUrl, flight);
683
+ return flight;
684
+ }
685
+
553
686
  /** A fetch marker carrying its last dependency snapshot for refetch diffing and
554
687
  * a generation token so a superseded in-flight response/retry bails on write. */
555
688
  /** @typedef {Element & { __wdSnap?: string, __wdGen?: number }} FetchNode */
package/src/scaffold.js CHANGED
@@ -128,10 +128,19 @@ export function initProject(root) {
128
128
  "",
129
129
  "Run `npm install` and `npm run dev` to start the live compiler.",
130
130
  "",
131
+ "## Commands",
132
+ "",
133
+ "- `npm run dev` — live compiler on http://localhost:5173 with hot reload.",
134
+ "- `npm run build` — compile the site to `dist/`.",
135
+ "- `npm run preview` — serve the built `dist/` locally.",
136
+ "",
137
+ "## Layout",
138
+ "",
131
139
  "- Pages live in `site/pages`.",
132
140
  "- Shared includes live in `site/_`.",
133
141
  "- Hidden route files start with `.` or `-`.",
134
- "- Colocated `index.skin` and `index.js` attach automatically."
142
+ "- Colocated `index.skin` and `index.js` attach automatically.",
143
+ "- Any other file under `site/pages` (images, fonts, …) copies to `dist/` at its own path."
135
144
  ].join("\n"));
136
145
  return { root };
137
146
  }
package/src/statics.js CHANGED
@@ -65,6 +65,7 @@ const contentTypes = {
65
65
  ".jpeg": "image/jpeg",
66
66
  ".gif": "image/gif",
67
67
  ".webp": "image/webp",
68
+ ".avif": "image/avif",
68
69
  ".ico": "image/x-icon",
69
70
  ".woff": "font/woff",
70
71
  ".woff2": "font/woff2",