@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/AGENTS.md +25 -7
- package/CHANGELOG.md +20 -0
- package/README.md +79 -9
- package/SECURITY.md +1 -1
- package/docs/spec-alignment.md +19 -4
- package/package.json +2 -2
- package/site/_/empty.json +1 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/data.wd +41 -0
- package/site/pages/docs/index.wd +58 -7
- package/site/pages/index.wd +2 -2
- package/site/pages/reactive.wd +30 -7
- package/src/builder.js +32 -0
- package/src/compiler.js +271 -52
- package/src/runtime.js +149 -16
- package/src/scaffold.js +10 -1
- package/src/statics.js +1 -0
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)
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
}
|