@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.
Files changed (57) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +115 -5
  4. package/docs/spec-alignment.md +16 -3
  5. package/package.json +3 -3
  6. package/site/_/base.skin +372 -0
  7. package/site/_/base.wd +4 -0
  8. package/site/_/footer.wd +14 -0
  9. package/site/_/metrics.json +13 -0
  10. package/site/_/nav.skin +35 -12
  11. package/site/_/nav.wd +8 -10
  12. package/site/_/products.json +10 -0
  13. package/site/pages/app.wd +1 -1
  14. package/site/pages/carousel/index.js +82 -0
  15. package/site/pages/carousel/index.skin +185 -0
  16. package/site/pages/carousel/index.wd +109 -0
  17. package/site/pages/carousel/slide-1.jpg +0 -0
  18. package/site/pages/carousel/slide-2.jpg +0 -0
  19. package/site/pages/carousel/slide-3.jpg +0 -0
  20. package/site/pages/carousel/slide-4.jpg +0 -0
  21. package/site/pages/carousel/slide-5.jpg +0 -0
  22. package/site/pages/compass/index.skin +247 -0
  23. package/site/pages/compass/index.wd +194 -0
  24. package/site/pages/docs/index.skin +48 -28
  25. package/site/pages/docs/index.wd +71 -5
  26. package/site/pages/folio/-style.skin +375 -0
  27. package/site/pages/folio/-style.wd +6 -0
  28. package/site/pages/folio/cart.skin +309 -0
  29. package/site/pages/folio/cart.wd +201 -0
  30. package/site/pages/folio/index.skin +24 -0
  31. package/site/pages/folio/index.wd +167 -0
  32. package/site/pages/forge/index.skin +676 -0
  33. package/site/pages/forge/index.wd +330 -0
  34. package/site/pages/index.skin +135 -144
  35. package/site/pages/index.wd +17 -5
  36. package/site/pages/ledger/index.skin +257 -0
  37. package/site/pages/ledger/index.wd +174 -0
  38. package/site/pages/media/clip-poster.jpg +0 -0
  39. package/site/pages/media/clip.mp4 +0 -0
  40. package/site/pages/media/index.skin +82 -0
  41. package/site/pages/media/index.wd +84 -0
  42. package/site/pages/media/tone.mp3 +0 -0
  43. package/site/pages/pulse/index.skin +434 -0
  44. package/site/pages/pulse/index.wd +178 -0
  45. package/site/pages/showcase/index.skin +130 -0
  46. package/site/pages/showcase/index.wd +168 -0
  47. package/src/compiler/body.js +26 -1
  48. package/src/compiler/context.js +1 -1
  49. package/src/compiler/directives.js +199 -3
  50. package/src/compiler/format.js +244 -0
  51. package/src/compiler/interpolation.js +35 -1
  52. package/src/compiler/loops.js +95 -24
  53. package/src/compiler/markdown.js +16 -7
  54. package/src/compiler/predicates.js +35 -2
  55. package/src/headers.js +5 -0
  56. package/src/runtime.js +168 -14
  57. 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 sort = region.getAttribute("data-wd-loop-sort");
306
- if (sort != null) {
307
- const dir = region.getAttribute("data-wd-loop-sort-dir") === "desc" ? -1 : 1;
308
- list = list.map((value, index) => ({ value, index })).sort((a, b) => {
309
- const av = getPath(a.value, sort || null);
310
- const bv = getPath(b.value, sort || null);
311
- const c = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
312
- return (c || a.index - b.index) * dir;
313
- }).map((w) => w.value);
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
- css.unshift(`@media (prefers-color-scheme: dark) {\n:root {\n${darkVars.join("\n")}\n}\n}`);
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");