@zvndev/darkmown 0.20.0 → 1.0.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 +1 -1
- package/CHANGELOG.md +13 -0
- package/README.md +115 -5
- package/docs/spec-alignment.md +16 -3
- package/package.json +3 -3
- package/site/_/base.skin +372 -0
- package/site/_/base.wd +4 -0
- package/site/_/footer.wd +14 -0
- package/site/_/metrics.json +13 -0
- package/site/_/nav.skin +35 -12
- package/site/_/nav.wd +8 -10
- package/site/_/products.json +10 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/carousel/index.js +82 -0
- package/site/pages/carousel/index.skin +185 -0
- package/site/pages/carousel/index.wd +109 -0
- package/site/pages/carousel/slide-1.jpg +0 -0
- package/site/pages/carousel/slide-2.jpg +0 -0
- package/site/pages/carousel/slide-3.jpg +0 -0
- package/site/pages/carousel/slide-4.jpg +0 -0
- package/site/pages/carousel/slide-5.jpg +0 -0
- package/site/pages/compass/index.skin +247 -0
- package/site/pages/compass/index.wd +194 -0
- package/site/pages/docs/index.skin +48 -28
- package/site/pages/docs/index.wd +71 -5
- package/site/pages/folio/-style.skin +375 -0
- package/site/pages/folio/-style.wd +6 -0
- package/site/pages/folio/cart.skin +309 -0
- package/site/pages/folio/cart.wd +201 -0
- package/site/pages/folio/index.skin +24 -0
- package/site/pages/folio/index.wd +167 -0
- package/site/pages/forge/index.skin +676 -0
- package/site/pages/forge/index.wd +330 -0
- package/site/pages/index.skin +135 -144
- package/site/pages/index.wd +17 -5
- package/site/pages/ledger/index.skin +257 -0
- package/site/pages/ledger/index.wd +174 -0
- package/site/pages/media/clip-poster.jpg +0 -0
- package/site/pages/media/clip.mp4 +0 -0
- package/site/pages/media/index.skin +82 -0
- package/site/pages/media/index.wd +84 -0
- package/site/pages/media/tone.mp3 +0 -0
- package/site/pages/pulse/index.skin +434 -0
- package/site/pages/pulse/index.wd +178 -0
- package/site/pages/showcase/index.skin +130 -0
- package/site/pages/showcase/index.wd +168 -0
- package/src/compiler/body.js +26 -1
- package/src/compiler/context.js +1 -1
- package/src/compiler/directives.js +199 -3
- package/src/compiler/format.js +244 -0
- package/src/compiler/interpolation.js +35 -1
- package/src/compiler/loops.js +95 -24
- package/src/compiler/markdown.js +16 -7
- package/src/compiler/predicates.js +35 -2
- package/src/headers.js +5 -0
- package/src/runtime.js +168 -14
- package/src/skin.js +9 -1
package/src/runtime.js
CHANGED
|
@@ -119,6 +119,75 @@ function isEmpty(v) {
|
|
|
119
119
|
return v == null || (typeof v === "object" && (Array.isArray(v) ? v.length : Object.keys(v).length) === 0);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// --- Format pipes -----------------------------------------------------------
|
|
123
|
+
// A compact mirror of src/compiler/format.js: the same formatter math, so a
|
|
124
|
+
// reactive `{ value | money }` re-formats in the browser exactly as the static
|
|
125
|
+
// fold did at build time. tests/format-parity.test.js guards the two against
|
|
126
|
+
// drift. Pure functions of (value, args) — no clock, no DOM — Intl does the work.
|
|
127
|
+
|
|
128
|
+
/** @param {any} v @returns {number} */
|
|
129
|
+
const fmtNum = (v) => { const n = typeof v === "number" ? v : Number.parseFloat(v); return Number.isFinite(n) ? n : 0; };
|
|
130
|
+
/** @param {any} v @returns {any[]} */
|
|
131
|
+
const fmtList = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]);
|
|
132
|
+
/** @param {any} row @param {string} [f] */
|
|
133
|
+
const fmtPick = (row, f) => (f != null && f !== "" ? (row == null ? undefined : row[f]) : row);
|
|
134
|
+
/** @param {any[]} list @param {string} [f] */
|
|
135
|
+
const fmtNums = (list, f) => list.map((r) => fmtNum(fmtPick(r, f)));
|
|
136
|
+
/** @param {any} v @returns {Date} */
|
|
137
|
+
const fmtToDate = (v) => {
|
|
138
|
+
if (v instanceof Date) return v;
|
|
139
|
+
if (typeof v === "number") return new Date(v);
|
|
140
|
+
const s = String(v).trim();
|
|
141
|
+
return /^-?\d+$/.test(s) ? new Date(Number(s)) : new Date(s);
|
|
142
|
+
};
|
|
143
|
+
/** @param {Date} d @param {any} opts @param {any} fb */
|
|
144
|
+
const fmtDate = (d, opts, fb) => (Number.isNaN(d.getTime()) ? String(fb) : new Intl.DateTimeFormat(undefined, opts).format(d));
|
|
145
|
+
|
|
146
|
+
/** @type {Record<string, (value: any, args: any[]) => any>} */
|
|
147
|
+
const FMT = {
|
|
148
|
+
money: (v, [c = "USD", l]) => new Intl.NumberFormat(l || undefined, { style: "currency", currency: String(c || "USD") }).format(fmtNum(v)),
|
|
149
|
+
number: (v, [d]) => new Intl.NumberFormat(undefined, d == null ? {} : { minimumFractionDigits: +d, maximumFractionDigits: +d }).format(fmtNum(v)),
|
|
150
|
+
percent: (v, [d = 0]) => new Intl.NumberFormat(undefined, { style: "percent", minimumFractionDigits: +d, maximumFractionDigits: +d }).format(fmtNum(v)),
|
|
151
|
+
round: (v, [d = 0]) => { const p = 10 ** +d; return Math.round(fmtNum(v) * p) / p; },
|
|
152
|
+
date: (v, [s = "medium"]) => fmtDate(fmtToDate(v), { dateStyle: s }, v),
|
|
153
|
+
time: (v, [s = "short"]) => fmtDate(fmtToDate(v), { timeStyle: s }, v),
|
|
154
|
+
datetime: (v, [ds = "medium", ts = "short"]) => fmtDate(fmtToDate(v), { dateStyle: ds, timeStyle: ts }, v),
|
|
155
|
+
upper: (v) => String(v ?? "").toUpperCase(),
|
|
156
|
+
lower: (v) => String(v ?? "").toLowerCase(),
|
|
157
|
+
capitalize: (v) => { const s = String(v ?? ""); return s ? s[0].toUpperCase() + s.slice(1) : s; },
|
|
158
|
+
truncate: (v, [n = 50, suf = "…"]) => { const s = String(v ?? ""); return s.length > +n ? s.slice(0, +n).trimEnd() + String(suf) : s; },
|
|
159
|
+
trim: (v) => String(v ?? "").trim(),
|
|
160
|
+
pluralize: (v, [sg = "", pl]) => { const n = fmtNum(v); return `${new Intl.NumberFormat().format(n)} ${Math.abs(n) === 1 ? String(sg) : pl != null ? String(pl) : `${sg}s`}`; },
|
|
161
|
+
default: (v, [fb = ""]) => (v == null || v === "" || (Array.isArray(v) && !v.length) ? String(fb) : v),
|
|
162
|
+
sum: (v, [f]) => fmtNums(fmtList(v), f).reduce((a, n) => a + n, 0),
|
|
163
|
+
avg: (v, [f]) => { const l = fmtList(v); return l.length ? FMT.sum(l, [f]) / l.length : 0; },
|
|
164
|
+
min: (v, [f]) => { const l = fmtList(v); return l.length ? Math.min(...fmtNums(l, f)) : 0; },
|
|
165
|
+
max: (v, [f]) => { const l = fmtList(v); return l.length ? Math.max(...fmtNums(l, f)) : 0; },
|
|
166
|
+
count: (v) => fmtList(v).length,
|
|
167
|
+
join: (v, [sep = ", ", f]) => fmtList(v).map((r) => fmtPick(r, f)).join(String(sep))
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Run a node's `data-wd-fmt` pipe chain over a resolved value; identity when the
|
|
172
|
+
* node carries no chain. Shared by text binds, loop-row binds, and meta markers.
|
|
173
|
+
* @param {Element} node
|
|
174
|
+
* @param {any} value
|
|
175
|
+
* @returns {any}
|
|
176
|
+
*/
|
|
177
|
+
function applyFmt(node, value) {
|
|
178
|
+
const raw = node.getAttribute("data-wd-fmt");
|
|
179
|
+
if (!raw) return value;
|
|
180
|
+
let stages;
|
|
181
|
+
try { stages = JSON.parse(raw); } catch { return value; }
|
|
182
|
+
let out = value;
|
|
183
|
+
for (const [name, args] of stages) { if (FMT[name]) out = FMT[name](out, args || []); }
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Aggregate helper exposed to `:computed` as `A("sum", list, "field")`. */
|
|
188
|
+
const AGG = (/** @type {string} */ name, /** @type {any} */ list, /** @type {string} */ field) =>
|
|
189
|
+
FMT[name] ? FMT[name](list, field == null ? [] : [field]) : undefined;
|
|
190
|
+
|
|
122
191
|
/**
|
|
123
192
|
* Shared HTTP→JSON core for `:fetch` and the round-trip `:form`. Sends the
|
|
124
193
|
* request, throws on non-2xx, and parses the body as JSON (falling back to a
|
|
@@ -142,7 +211,7 @@ async function httpJson(url, init) {
|
|
|
142
211
|
const computedDefs = [...document.querySelectorAll("[data-wd-computed]")].map((node) => ({
|
|
143
212
|
key: node.getAttribute("data-wd-computed-key") || "",
|
|
144
213
|
expr: node.getAttribute("data-wd-computed-expr") || "",
|
|
145
|
-
evaluate: new Function("S", `return (${node.getAttribute("data-wd-computed-expr")});`)
|
|
214
|
+
evaluate: new Function("S", "A", `return (${node.getAttribute("data-wd-computed-expr")});`)
|
|
146
215
|
}));
|
|
147
216
|
|
|
148
217
|
/**
|
|
@@ -165,7 +234,7 @@ function recompute() {
|
|
|
165
234
|
const read = (key, path) => getPath(state[key], path ?? null);
|
|
166
235
|
for (const def of computedDefs) {
|
|
167
236
|
try {
|
|
168
|
-
state[def.key] = def.evaluate(read);
|
|
237
|
+
state[def.key] = def.evaluate(read, AGG);
|
|
169
238
|
} catch (error) {
|
|
170
239
|
state[def.key] = undefined;
|
|
171
240
|
warn(def.expr, error);
|
|
@@ -269,11 +338,11 @@ function fillItem(node, item, meta = {}) {
|
|
|
269
338
|
: [...node.querySelectorAll("[data-wd-each]")];
|
|
270
339
|
for (const target of targets) {
|
|
271
340
|
if (skip(target)) continue;
|
|
272
|
-
target.textContent = getPath(item, target.getAttribute("data-wd-path")) ?? "";
|
|
341
|
+
target.textContent = applyFmt(target, getPath(item, target.getAttribute("data-wd-path"))) ?? "";
|
|
273
342
|
}
|
|
274
343
|
for (const marker of node.querySelectorAll("[data-wd-each-meta]")) {
|
|
275
344
|
if (skip(marker)) continue;
|
|
276
|
-
marker.textContent = meta[marker.getAttribute("data-wd-each-meta") || ""] ?? "";
|
|
345
|
+
marker.textContent = applyFmt(marker, meta[marker.getAttribute("data-wd-each-meta") || ""]) ?? "";
|
|
277
346
|
}
|
|
278
347
|
const classNodes = node.matches("[data-wd-each-class]") ? [node, ...node.querySelectorAll("[data-wd-each-class]")] : [...node.querySelectorAll("[data-wd-each-class]")];
|
|
279
348
|
for (const el of classNodes) { if (!skip(el)) classToggle(el, el.getAttribute("data-wd-each-class"), item); }
|
|
@@ -302,15 +371,24 @@ function loopNum(region, name) {
|
|
|
302
371
|
* @returns {any[]}
|
|
303
372
|
*/
|
|
304
373
|
function pipeline(list, region) {
|
|
305
|
-
const
|
|
306
|
-
if (
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
374
|
+
const sortAttr = region.getAttribute("data-wd-loop-sort");
|
|
375
|
+
if (sortAttr != null) {
|
|
376
|
+
// `key:<name>` reads the sort field / direction from state (reactive,
|
|
377
|
+
// clickable-header sort); a plain value is the literal `sort by` clause.
|
|
378
|
+
const reactive = sortAttr.startsWith("key:");
|
|
379
|
+
const sort = reactive ? String(state[sortAttr.slice(4)] ?? "") : sortAttr;
|
|
380
|
+
// A reactive sort with no column chosen yet (state is "") leaves the order.
|
|
381
|
+
if (!reactive || sort) {
|
|
382
|
+
let dirAttr = region.getAttribute("data-wd-loop-sort-dir") || "asc";
|
|
383
|
+
if (dirAttr.startsWith("key:")) dirAttr = String(state[dirAttr.slice(4)] ?? "asc");
|
|
384
|
+
const dir = dirAttr === "desc" ? -1 : 1;
|
|
385
|
+
list = list.map((value, index) => ({ value, index })).sort((a, b) => {
|
|
386
|
+
const av = getPath(a.value, sort || null);
|
|
387
|
+
const bv = getPath(b.value, sort || null);
|
|
388
|
+
const c = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
|
|
389
|
+
return (c || a.index - b.index) * dir;
|
|
390
|
+
}).map((w) => w.value);
|
|
391
|
+
}
|
|
314
392
|
}
|
|
315
393
|
if (region.hasAttribute("data-wd-loop-reverse")) list.reverse();
|
|
316
394
|
const off = loopNum(region, "data-wd-loop-offset");
|
|
@@ -438,14 +516,29 @@ function renderNow() {
|
|
|
438
516
|
}
|
|
439
517
|
|
|
440
518
|
for (const node of document.querySelectorAll("[data-wd-bind]")) {
|
|
441
|
-
node.textContent = getPath(state[node.getAttribute("data-wd-bind") || ""], node.getAttribute("data-wd-path")) ?? "";
|
|
519
|
+
node.textContent = applyFmt(node, getPath(state[node.getAttribute("data-wd-bind") || ""], node.getAttribute("data-wd-path"))) ?? "";
|
|
442
520
|
}
|
|
443
521
|
|
|
444
522
|
for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
|
|
445
523
|
if (document.activeElement !== input) /** @type {HTMLInputElement} */ (input).value = state[input.getAttribute("data-wd-bind-input") || ""] ?? "";
|
|
446
524
|
}
|
|
447
525
|
|
|
526
|
+
if (themeNodes.length) reflectThemes();
|
|
448
527
|
runEffects(); // side effects run last, against fully settled state
|
|
528
|
+
if (subscribers.size) notifySubscribers();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** :theme — reflect a store value onto <html data-theme> so a manual light/dark
|
|
532
|
+
* switch layers over the OS preference: `auto`/empty clears the attribute (follow
|
|
533
|
+
* the system via the skin's `tokens dark` media query), anything else forces it. */
|
|
534
|
+
const themeNodes = [...document.querySelectorAll("[data-wd-theme]")];
|
|
535
|
+
function reflectThemes() {
|
|
536
|
+
for (const node of themeNodes) {
|
|
537
|
+
const value = state[node.getAttribute("data-wd-theme") || ""];
|
|
538
|
+
const root = document.documentElement;
|
|
539
|
+
if (value == null || value === "" || value === "auto") root.removeAttribute("data-theme");
|
|
540
|
+
else root.setAttribute("data-theme", String(value));
|
|
541
|
+
}
|
|
449
542
|
}
|
|
450
543
|
|
|
451
544
|
/** @typedef {{ watch: string, actions: { op: string, target?: string, value?: any }[], last: string }} Effect */
|
|
@@ -755,6 +848,52 @@ for (const node of fetchNodes) {
|
|
|
755
848
|
}
|
|
756
849
|
}
|
|
757
850
|
|
|
851
|
+
// :every — run actions on a timer, paused while the tab is hidden so background
|
|
852
|
+
// tabs don't poll or animate. Each tick runs the action sequence (no clicked row,
|
|
853
|
+
// like :effect), then saves + renders + checks refetch deps, exactly like a click.
|
|
854
|
+
/** @typedef {{ ms: number, actions: { op: string, target?: string, value?: any }[] }} EveryDef */
|
|
855
|
+
/** @type {EveryDef[]} */
|
|
856
|
+
const everyDefs = [...document.querySelectorAll("script[data-wd-every]")].map((node) => JSON.parse(node.textContent || "{}"));
|
|
857
|
+
/** @type {any[]} */
|
|
858
|
+
let everyTimers = [];
|
|
859
|
+
function startEvery() {
|
|
860
|
+
if (everyTimers.length || document.hidden) return;
|
|
861
|
+
for (const def of everyDefs) {
|
|
862
|
+
if (!def.ms || !def.actions) continue;
|
|
863
|
+
everyTimers.push(setInterval(() => {
|
|
864
|
+
for (const a of def.actions) applyAction(null, a.op, a.target || "", a.value);
|
|
865
|
+
savePersisted();
|
|
866
|
+
render();
|
|
867
|
+
checkRefetch();
|
|
868
|
+
}, def.ms));
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
function stopEvery() {
|
|
872
|
+
for (const timer of everyTimers) clearInterval(timer);
|
|
873
|
+
everyTimers = [];
|
|
874
|
+
}
|
|
875
|
+
if (everyDefs.length) {
|
|
876
|
+
document.addEventListener("visibilitychange", () => (document.hidden ? stopEvery() : startEvery()));
|
|
877
|
+
startEvery();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Escape-hatch subscriptions: colocated `.js` "behaviors" (carousels, drag-and-
|
|
881
|
+
// drop, charts, maps) react to state with `wd.subscribe(key, cb)`. Each subscribe
|
|
882
|
+
// primes the callback with the current value, then fires on every settled change.
|
|
883
|
+
/** @typedef {{ cbs: Set<(value: any) => void>, last: string }} SubEntry */
|
|
884
|
+
/** @type {Map<string, SubEntry>} */
|
|
885
|
+
const subscribers = new Map();
|
|
886
|
+
function notifySubscribers() {
|
|
887
|
+
for (const [key, entry] of subscribers) {
|
|
888
|
+
const now = JSON.stringify(state[key] ?? null);
|
|
889
|
+
if (now === entry.last) continue;
|
|
890
|
+
entry.last = now;
|
|
891
|
+
for (const cb of entry.cbs) {
|
|
892
|
+
try { cb(state[key]); } catch (error) { warn(`subscribe ${key}`, error); }
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
758
897
|
/**
|
|
759
898
|
* Public escape hatch on `window.wd`. Set `window.wd.debug = true` to log failing
|
|
760
899
|
* computed/where expressions to the console.
|
|
@@ -770,6 +909,21 @@ for (const node of fetchNodes) {
|
|
|
770
909
|
savePersisted();
|
|
771
910
|
render();
|
|
772
911
|
},
|
|
912
|
+
/**
|
|
913
|
+
* Run `cb(value)` now and on every future change to `state[key]`. Returns an
|
|
914
|
+
* unsubscribe function. The bridge for colocated `.js` behaviors.
|
|
915
|
+
* @param {string} key @param {(value: any) => void} cb
|
|
916
|
+
*/
|
|
917
|
+
subscribe: (key, cb) => {
|
|
918
|
+
let entry = subscribers.get(key);
|
|
919
|
+
if (!entry) {
|
|
920
|
+
entry = { cbs: new Set(), last: JSON.stringify(state[key] ?? null) };
|
|
921
|
+
subscribers.set(key, entry);
|
|
922
|
+
}
|
|
923
|
+
entry.cbs.add(cb);
|
|
924
|
+
try { cb(state[key]); } catch (error) { warn(`subscribe ${key}`, error); }
|
|
925
|
+
return () => entry.cbs.delete(cb);
|
|
926
|
+
},
|
|
773
927
|
render: renderNow
|
|
774
928
|
};
|
|
775
929
|
|
package/src/skin.js
CHANGED
|
@@ -139,7 +139,15 @@ export function compileSkin(source) {
|
|
|
139
139
|
if (vars.length) css.unshift(`${selector} {\n${vars.join("\n")}\n}`);
|
|
140
140
|
}
|
|
141
141
|
if (darkVars.length) {
|
|
142
|
-
|
|
142
|
+
const body = darkVars.join("\n");
|
|
143
|
+
// One `tokens dark` block powers BOTH OS-auto and a manual :theme toggle.
|
|
144
|
+
// Forced `[data-theme="dark"]` is always dark; the OS-dark media query applies
|
|
145
|
+
// unless the visitor forced light. Both rules reference the same tokens, so a
|
|
146
|
+
// user override wins without duplicating the palette.
|
|
147
|
+
css.unshift(`:root[data-theme="dark"] {\n${body}\n}`);
|
|
148
|
+
css.unshift(
|
|
149
|
+
`@media (prefers-color-scheme: dark) {\n:root:not([data-theme="light"]) {\n${body}\n}\n}`
|
|
150
|
+
);
|
|
143
151
|
}
|
|
144
152
|
if (rootVars.length) css.unshift(`:root {\n${rootVars.join("\n")}\n}`);
|
|
145
153
|
return css.join("\n");
|