@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
@@ -0,0 +1,244 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Format pipes — the value layer. `{ value | name:arg:arg | name2 }` runs a
3
+ // resolved value through a whitelist of formatters; `{ list | sum:"price" }`
4
+ // aggregates a list. The same formatter math is mirrored, compactly, in
5
+ // src/runtime.js for the reactive path — tests/format.test.js + the compile↔
6
+ // runtime parity test guard against drift. Nothing here is eval'd: pipe stages
7
+ // are a fixed, compile-time-validated registry, exactly like the predicate and
8
+ // action grammars.
9
+ //
10
+ // Folds at build time when the piped value is static (zero-JS); a value that
11
+ // reads :state/:store emits `data-wd-fmt` and re-formats in the runtime.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * @typedef {import("./context.js").Ctx} Ctx
16
+ * @typedef {{ name: string, args: Array<string | number | boolean | null> }} Stage
17
+ */
18
+
19
+ // --- shared math (kept tiny and mirrored in the runtime) -------------------
20
+
21
+ /** @param {unknown} v @returns {number} */
22
+ const toNum = (v) => {
23
+ const n = typeof v === "number" ? v : Number.parseFloat(String(v));
24
+ return Number.isFinite(n) ? n : 0;
25
+ };
26
+ /** @param {unknown} v @returns {any[]} */
27
+ const toList = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]);
28
+ /** Read one shallow field off a row, or the row itself when no field is given. */
29
+ const pick = (/** @type {any} */ row, /** @type {string} */ f) =>
30
+ f != null && f !== "" ? (row == null ? undefined : row[f]) : row;
31
+ /** @param {any[]} list @param {string} f @returns {number[]} */
32
+ const nums = (list, f) => list.map((row) => toNum(pick(row, f)));
33
+
34
+ /**
35
+ * Coerce ISO strings, epoch milliseconds (number or numeric string), or a Date
36
+ * into a Date. Returns an invalid Date for unparseable input (formatters guard).
37
+ * @param {unknown} v
38
+ * @returns {Date}
39
+ */
40
+ function toDate(v) {
41
+ if (v instanceof Date) return v;
42
+ if (typeof v === "number") return new Date(v);
43
+ const s = String(v).trim();
44
+ return /^-?\d+$/.test(s) ? new Date(Number(s)) : new Date(s);
45
+ }
46
+
47
+ /** @param {Date} d @param {Intl.DateTimeFormatOptions} opts @param {string} fallback */
48
+ const fmtDate = (d, opts, fallback) =>
49
+ Number.isNaN(d.getTime()) ? String(fallback) : new Intl.DateTimeFormat(undefined, opts).format(d);
50
+
51
+ // --- aggregates (shared by pipes and `:computed`) --------------------------
52
+
53
+ /** @type {Record<string, (list: any[], field?: string) => number>} */
54
+ export const AGGREGATES = {
55
+ sum: (list, f) => nums(list, f || "").reduce((a, n) => a + n, 0),
56
+ avg: (list, f) => (list.length ? AGGREGATES.sum(list, f) / list.length : 0),
57
+ min: (list, f) => (list.length ? Math.min(...nums(list, f || "")) : 0),
58
+ max: (list, f) => (list.length ? Math.max(...nums(list, f || "")) : 0),
59
+ count: (list) => list.length
60
+ };
61
+
62
+ // --- the formatter registry ------------------------------------------------
63
+
64
+ /**
65
+ * The whitelist. Each formatter is a pure function of (value, args) — no clock,
66
+ * no DOM, no globals beyond `Intl` — so a static value folds deterministically at
67
+ * build time and a reactive value formats identically in the browser.
68
+ * @type {Record<string, (value: any, args: any[]) => string | number>}
69
+ */
70
+ export const FORMATTERS = {
71
+ // numbers / money
72
+ money: (v, [currency = "USD", locale]) =>
73
+ new Intl.NumberFormat(locale || undefined, {
74
+ style: "currency",
75
+ currency: String(currency || "USD")
76
+ }).format(toNum(v)),
77
+ number: (v, [dec]) =>
78
+ new Intl.NumberFormat(
79
+ undefined,
80
+ dec == null ? {} : { minimumFractionDigits: +dec, maximumFractionDigits: +dec }
81
+ ).format(toNum(v)),
82
+ percent: (v, [dec = 0]) =>
83
+ new Intl.NumberFormat(undefined, {
84
+ style: "percent",
85
+ minimumFractionDigits: +dec,
86
+ maximumFractionDigits: +dec
87
+ }).format(toNum(v)),
88
+ round: (v, [dec = 0]) => {
89
+ const p = 10 ** +dec;
90
+ return Math.round(toNum(v) * p) / p;
91
+ },
92
+ // dates (format a given instant — pure, no "now")
93
+ date: (v, [style = "medium"]) => fmtDate(toDate(v), { dateStyle: /** @type {any} */ (style) }, v),
94
+ time: (v, [style = "short"]) => fmtDate(toDate(v), { timeStyle: /** @type {any} */ (style) }, v),
95
+ datetime: (v, [ds = "medium", ts = "short"]) =>
96
+ fmtDate(
97
+ toDate(v),
98
+ { dateStyle: /** @type {any} */ (ds), timeStyle: /** @type {any} */ (ts) },
99
+ v
100
+ ),
101
+ // text
102
+ upper: (v) => String(v ?? "").toUpperCase(),
103
+ lower: (v) => String(v ?? "").toLowerCase(),
104
+ capitalize: (v) => {
105
+ const s = String(v ?? "");
106
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
107
+ },
108
+ truncate: (v, [length = 50, suffix = "…"]) => {
109
+ const s = String(v ?? "");
110
+ return s.length > +length ? s.slice(0, +length).trimEnd() + String(suffix) : s;
111
+ },
112
+ trim: (v) => String(v ?? "").trim(),
113
+ pluralize: (v, [singular = "", plural]) => {
114
+ const n = toNum(v);
115
+ const word =
116
+ Math.abs(n) === 1 ? String(singular) : plural != null ? String(plural) : `${singular}s`;
117
+ return `${new Intl.NumberFormat().format(n)} ${word}`;
118
+ },
119
+ default: (v, [fallback = ""]) => {
120
+ const empty = v == null || v === "" || (Array.isArray(v) && v.length === 0);
121
+ return empty ? String(fallback) : v;
122
+ },
123
+ // aggregates (operate on a list)
124
+ sum: (v, [f]) => AGGREGATES.sum(toList(v), f),
125
+ avg: (v, [f]) => AGGREGATES.avg(toList(v), f),
126
+ min: (v, [f]) => AGGREGATES.min(toList(v), f),
127
+ max: (v, [f]) => AGGREGATES.max(toList(v), f),
128
+ count: (v) => AGGREGATES.count(toList(v)),
129
+ join: (v, [sep = ", ", f]) =>
130
+ toList(v)
131
+ .map((row) => pick(row, /** @type {string} */ (f)))
132
+ .join(String(sep))
133
+ };
134
+
135
+ /** Formatter names, for error messages and the runtime mirror check. */
136
+ export const FORMATTER_NAMES = Object.keys(FORMATTERS);
137
+
138
+ // --- parsing ---------------------------------------------------------------
139
+
140
+ /**
141
+ * Split a string on a separator that is not inside a quoted run. Keeps pipe
142
+ * arguments like `"a | b"` or `":"` intact.
143
+ * @param {string} s
144
+ * @param {string} sep single character
145
+ * @returns {string[]}
146
+ */
147
+ function splitTop(s, sep) {
148
+ /** @type {string[]} */
149
+ const out = [];
150
+ let cur = "";
151
+ let quote = "";
152
+ for (const c of s) {
153
+ if (quote) {
154
+ cur += c;
155
+ if (c === quote) quote = "";
156
+ } else if (c === '"' || c === "'") {
157
+ quote = c;
158
+ cur += c;
159
+ } else if (c === sep) {
160
+ out.push(cur);
161
+ cur = "";
162
+ } else cur += c;
163
+ }
164
+ out.push(cur);
165
+ return out;
166
+ }
167
+
168
+ /** @param {string} t @returns {string | number | boolean | null} */
169
+ function parseArg(t) {
170
+ const v = t.trim();
171
+ if (/^"[^"]*"$/.test(v) || /^'[^']*'$/.test(v)) return v.slice(1, -1);
172
+ if (/^-?\d+(?:\.\d+)?$/.test(v)) return Number(v);
173
+ if (v === "true") return true;
174
+ if (v === "false") return false;
175
+ if (v === "null") return null;
176
+ return v;
177
+ }
178
+
179
+ /**
180
+ * Parse the inside of a `{ … }` binding into its path and an ordered list of
181
+ * pipe stages. `{ price | money }` → `{ path: "price", stages: [{name:"money",args:[]}] }`.
182
+ * Stage validation (unknown formatter) happens in {@link validatePipes}.
183
+ * @param {string} inner
184
+ * @returns {{ path: string, stages: Stage[] }}
185
+ */
186
+ export function parsePipes(inner) {
187
+ const segs = splitTop(inner, "|");
188
+ const path = segs[0].trim();
189
+ /** @type {Stage[]} */
190
+ const stages = [];
191
+ for (let i = 1; i < segs.length; i++) {
192
+ const raw = segs[i].trim();
193
+ const m = /^([A-Za-z][A-Za-z0-9_]*)\s*(?::([\s\S]*))?$/.exec(raw);
194
+ if (!m) return { path, stages: [{ name: raw, args: [] }] }; // malformed → flagged by validate
195
+ stages.push({ name: m[1], args: m[2] == null ? [] : splitTop(m[2], ":").map(parseArg) });
196
+ }
197
+ return { path, stages };
198
+ }
199
+
200
+ /**
201
+ * Compile-time validation: every stage must name a known formatter. Throws a
202
+ * corrective compile error otherwise. Returns the parsed `{ path, stages }`.
203
+ * @param {string} inner
204
+ * @param {Ctx} ctx
205
+ * @returns {{ path: string, stages: Stage[] }}
206
+ */
207
+ export function validatePipes(inner, ctx) {
208
+ const parsed = parsePipes(inner);
209
+ for (const stage of parsed.stages) {
210
+ if (!Object.hasOwn(FORMATTERS, stage.name)) {
211
+ throw new Error(
212
+ `Unknown formatter "${stage.name}" in { ${inner} } (${ctx.file}). Available: ${FORMATTER_NAMES.join(", ")}.`
213
+ );
214
+ }
215
+ }
216
+ return parsed;
217
+ }
218
+
219
+ /**
220
+ * Run a resolved value through a pipe chain at build time. Returns the final
221
+ * value (a string or number); the caller stringifies for output. Mirrors the
222
+ * runtime's `applyFmt`.
223
+ * @param {any} value
224
+ * @param {Stage[]} stages
225
+ * @returns {any}
226
+ */
227
+ export function applyPipeline(value, stages) {
228
+ let out = value;
229
+ for (const stage of stages) out = FORMATTERS[stage.name](out, stage.args);
230
+ return out;
231
+ }
232
+
233
+ /** Serialize stages to the compact `[[name,[args]]]` form carried in data-wd-fmt. */
234
+ export const fmtAttr = (/** @type {Stage[]} */ stages) =>
235
+ JSON.stringify(stages.map((s) => [s.name, s.args]));
236
+
237
+ /**
238
+ * Parse a `data-wd-fmt` JSON payload (already HTML-decoded) back into stages.
239
+ * Mirrors the runtime's own parse so the initial paint and the live update agree.
240
+ * @param {string} json
241
+ * @returns {Stage[]}
242
+ */
243
+ export const stagesFromAttr = (json) =>
244
+ /** @type {[string, any[]][]} */ (JSON.parse(json)).map(([name, args]) => ({ name, args }));
@@ -119,10 +119,15 @@ export function safeScriptJson(value) {
119
119
  }
120
120
 
121
121
  /**
122
+ * Parse a single-line `:state`/`:store`/`:theme` value: quoted string, boolean,
123
+ * null, number, or JSON literal — falling back to the bare string. A value that
124
+ * opens a JSON array/object but won't parse throws (almost always a multi-line
125
+ * literal, which these single-line directives can't express).
122
126
  * @param {string} raw
127
+ * @param {string} [where] `file:line` for the error message.
123
128
  * @returns {unknown}
124
129
  */
125
- export function parseStateValue(raw) {
130
+ export function parseStateValue(raw, where) {
126
131
  const value = raw.trim();
127
132
  if (/^["'].*["']$/.test(value)) return stripQuotes(value);
128
133
  if (value === "true") return true;
@@ -132,6 +137,18 @@ export function parseStateValue(raw) {
132
137
  try {
133
138
  return JSON.parse(value);
134
139
  } catch {
140
+ // A value that opens a JSON array/object but won't parse is almost always a
141
+ // multi-line literal — :state/:store/:theme take a single-line value, so the
142
+ // unterminated "[" used to get stored verbatim as the string "[" and silently
143
+ // break the page. Fail loudly with the fix instead. Quote it to keep literal
144
+ // brackets (e.g. `= "[draft]"`).
145
+ if (/^[[{]/.test(value))
146
+ throw new Error(
147
+ `Invalid value ${value}${where ? ` in ${where}` : ""}: this looks like the start of a ` +
148
+ `multi-line array/object, but :state/:store values must fit on one line. ` +
149
+ `Put the whole literal on one line, or quote it for literal text. ` +
150
+ `Use: name = [{"id":1,"label":"…"}]`
151
+ );
135
152
  return value;
136
153
  }
137
154
  }
@@ -187,3 +204,20 @@ export function escapeHtml(value) {
187
204
  .replaceAll('"', "&quot;")
188
205
  .replaceAll("'", "&#39;");
189
206
  }
207
+
208
+ /**
209
+ * Reverse {@link escapeHtml}. Used by the loop initial-paint fill to read a
210
+ * `data-wd-fmt` attribute (HTML-escaped JSON) back into pipe stages at build
211
+ * time — the browser does the same decode for the runtime via getAttribute.
212
+ * `&amp;` is decoded last so `&amp;lt;` round-trips to `&lt;`, not `<`.
213
+ * @param {string} value
214
+ * @returns {string}
215
+ */
216
+ export function unescapeHtml(value) {
217
+ return String(value)
218
+ .replaceAll("&lt;", "<")
219
+ .replaceAll("&gt;", ">")
220
+ .replaceAll("&quot;", '"')
221
+ .replaceAll("&#39;", "'")
222
+ .replaceAll("&amp;", "&");
223
+ }
@@ -9,8 +9,16 @@
9
9
  import fs from "node:fs";
10
10
  import { compileBody } from "./body.js";
11
11
  import { createScope } from "./context.js";
12
+ import { applyPipeline, stagesFromAttr } from "./format.js";
12
13
  import { resolveInclude } from "./includes.js";
13
- import { escapeHtml, getPath, lookupPath, resolveStateKey, stripQuotes } from "./interpolation.js";
14
+ import {
15
+ escapeHtml,
16
+ getPath,
17
+ lookupPath,
18
+ resolveStateKey,
19
+ stripQuotes,
20
+ unescapeHtml
21
+ } from "./interpolation.js";
14
22
  import { compilePredicate, evalPredicate } from "./predicates.js";
15
23
 
16
24
  /**
@@ -29,7 +37,7 @@ const LOOP_USAGE = "Use: @loop src into item [where …] [sort by …] [reverse]
29
37
  * @param {string} tail Everything after `@loop src into item`.
30
38
  * @param {string} itemName
31
39
  * @param {Ctx} ctx
32
- * @returns {{ where: string|null, sort: {key:string,dir:string}|null, reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean }}
40
+ * @returns {{ where: string|null, sort: import("./context.js").LoopOpts["sort"], reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean }}
33
41
  */
34
42
  function parseLoopClauses(tail, itemName, ctx) {
35
43
  // Peel clauses off the END in reverse fixed-order (limit, offset, reverse,
@@ -38,7 +46,7 @@ function parseLoopClauses(tail, itemName, ctx) {
38
46
  // remains must be `where …` (or empty); a clause keyword surviving in the
39
47
  // remainder means the author wrote the clauses out of order.
40
48
  let s = tail.trim();
41
- /** @type {{key:string,dir:string}|null} */ let sort = null;
49
+ /** @type {import("./context.js").LoopOpts["sort"]} */ let sort = null;
42
50
  let reverse = false;
43
51
  /** @type {NumArg|null} */ let offset = null;
44
52
  /** @type {NumArg|null} */ let limit = null;
@@ -68,18 +76,14 @@ function parseLoopClauses(tail, itemName, ctx) {
68
76
  s = s.slice(0, s.length - m[0].length + m[1].length).trim();
69
77
  }
70
78
 
71
- m = s.match(/(^|\s)sort\s+by\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(?:\s+(asc|desc))?$/);
79
+ // The field and direction may each be a literal (`post.date` / `asc|desc`) or a
80
+ // `{ state }` reference, which makes the sort reactive (clickable-header tables).
81
+ m = s.match(
82
+ /(^|\s)sort\s+by\s+(\{\s*[A-Za-z_$][\w$]*\s*\}|[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(?:\s+(\{\s*[A-Za-z_$][\w$]*\s*\}|asc|desc))?$/
83
+ );
72
84
  if (m) {
73
- const segs = m[2].split(".");
74
- if (segs[0] !== itemName)
75
- throw new Error(
76
- `@loop sort key "${m[2]}" must start with the loop item "${itemName}" in ${ctx.file}. ${LOOP_USAGE}`
77
- );
78
- const rest = segs.slice(1);
79
- if (rest.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
80
- throw new Error(`Sort key "${m[2]}" is not allowed in @loop (${ctx.file})`);
81
- }
82
- sort = { key: rest.join("."), dir: m[3] || "asc" };
85
+ sort = parseSortClause(m[2], m[3], itemName, ctx);
86
+ if (sort.keyKind === "key" || sort.dirKind === "key") refsState = true;
83
87
  s = s.slice(0, s.length - m[0].length + m[1].length).trim();
84
88
  }
85
89
 
@@ -95,6 +99,63 @@ function parseLoopClauses(tail, itemName, ctx) {
95
99
  return { where, sort, reverse, offset, limit, refsState };
96
100
  }
97
101
 
102
+ /**
103
+ * Parse the `sort by` field + optional direction. Each is a literal (an
104
+ * item-relative field path / `asc|desc`) or a `{ state }` reference; a reference
105
+ * resolves to a declared `:state`/`:store` key and makes the sort reactive.
106
+ * @param {string} fieldTok
107
+ * @param {string | undefined} dirTok
108
+ * @param {string} itemName
109
+ * @param {Ctx} ctx
110
+ * @returns {{ key: string, keyKind: "literal" | "key", dir: string, dirKind: "literal" | "key" }}
111
+ */
112
+ function parseSortClause(fieldTok, dirTok, itemName, ctx) {
113
+ /** @param {string} tok */
114
+ const stateRef = (tok) => tok.match(/^\{\s*([A-Za-z_$][\w$]*)\s*\}$/);
115
+
116
+ /** @type {string} */ let key;
117
+ /** @type {"literal" | "key"} */ let keyKind;
118
+ const fieldState = stateRef(fieldTok);
119
+ if (fieldState) {
120
+ const resolved = resolveStateKey(fieldState[1], ctx);
121
+ if (!resolved)
122
+ throw new Error(
123
+ `@loop sort by { ${fieldState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}`
124
+ );
125
+ key = resolved;
126
+ keyKind = "key";
127
+ } else {
128
+ const segs = fieldTok.split(".");
129
+ if (segs[0] !== itemName)
130
+ throw new Error(
131
+ `@loop sort key "${fieldTok}" must start with the loop item "${itemName}" in ${ctx.file}. ${LOOP_USAGE}`
132
+ );
133
+ const rest = segs.slice(1);
134
+ if (rest.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg)))
135
+ throw new Error(`Sort key "${fieldTok}" is not allowed in @loop (${ctx.file})`);
136
+ key = rest.join(".");
137
+ keyKind = "literal";
138
+ }
139
+
140
+ /** @type {string} */ let dir = "asc";
141
+ /** @type {"literal" | "key"} */ let dirKind = "literal";
142
+ if (dirTok) {
143
+ const dirState = stateRef(dirTok);
144
+ if (dirState) {
145
+ const resolved = resolveStateKey(dirState[1], ctx);
146
+ if (!resolved)
147
+ throw new Error(
148
+ `@loop sort direction { ${dirState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}`
149
+ );
150
+ dir = resolved;
151
+ dirKind = "key";
152
+ } else {
153
+ dir = dirTok;
154
+ }
155
+ }
156
+ return { key, keyKind, dir, dirKind };
157
+ }
158
+
98
159
  /**
99
160
  * @param {string} tok
100
161
  * @param {Ctx} ctx
@@ -406,7 +467,7 @@ function loopClauseAttrs(c) {
406
467
  return (
407
468
  (c.where ? ` data-wd-loop-where="${escapeHtml(c.where.body)}"` : "") +
408
469
  (c.sort
409
- ? ` data-wd-loop-sort="${escapeHtml(c.sort.key)}" data-wd-loop-sort-dir="${c.sort.dir}"`
470
+ ? ` data-wd-loop-sort="${escapeHtml(c.sort.keyKind === "key" ? `key:${c.sort.key}` : c.sort.key)}" data-wd-loop-sort-dir="${escapeHtml(c.sort.dirKind === "key" ? `key:${c.sort.dir}` : c.sort.dir)}"`
410
471
  : "") +
411
472
  (c.reverse ? ` data-wd-loop-reverse` : "") +
412
473
  (c.offset ? ` data-wd-loop-offset="${numArgAttr(c.offset)}"` : "") +
@@ -520,10 +581,15 @@ function fillTemplateString(template, item, meta = {}) {
520
581
  * @returns {string}
521
582
  */
522
583
  function fillEachMeta(str, meta) {
523
- return str.replace(/<span data-wd-each-meta="([a-z]+)"><\/span>/g, (_, name) => {
524
- const value = name in meta ? meta[name] : "";
525
- return `<span data-wd-each-meta="${name}">${escapeHtml(value ?? "")}</span>`;
526
- });
584
+ return str.replace(
585
+ /<span data-wd-each-meta="([a-z]+)"(?: data-wd-fmt="([^"]*)")?><\/span>/g,
586
+ (_, name, fmt) => {
587
+ let value = name in meta ? meta[name] : "";
588
+ if (fmt) value = applyPipeline(value, stagesFromAttr(unescapeHtml(fmt)));
589
+ const fmtAttr = fmt ? ` data-wd-fmt="${fmt}"` : "";
590
+ return `<span data-wd-each-meta="${name}"${fmtAttr}>${escapeHtml(value ?? "")}</span>`;
591
+ }
592
+ );
527
593
  }
528
594
 
529
595
  // Walk the string resolving only the OUTERMOST data-wd-each-if regions; each
@@ -580,11 +646,16 @@ function fillOneEachIf(region, item, meta) {
580
646
  * @returns {string}
581
647
  */
582
648
  function fillEachText(str, item) {
583
- return str.replace(/<span data-wd-each(?: data-wd-path="([^"]*)")?><\/span>/g, (_, p) => {
584
- const value = p ? getPath(item, p.split(".")) : item;
585
- const pathAttr = p ? ` data-wd-path="${p}"` : "";
586
- return `<span data-wd-each${pathAttr}>${escapeHtml(value ?? "")}</span>`;
587
- });
649
+ return str.replace(
650
+ /<span data-wd-each(?: data-wd-path="([^"]*)")?(?: data-wd-fmt="([^"]*)")?><\/span>/g,
651
+ (_, p, fmt) => {
652
+ let value = p ? getPath(item, p.split(".")) : item;
653
+ if (fmt) value = applyPipeline(value, stagesFromAttr(unescapeHtml(fmt)));
654
+ const pathAttr = p ? ` data-wd-path="${p}"` : "";
655
+ const fmtAttr = fmt ? ` data-wd-fmt="${fmt}"` : "";
656
+ return `<span data-wd-each${pathAttr}${fmtAttr}>${escapeHtml(value ?? "")}</span>`;
657
+ }
658
+ );
588
659
  }
589
660
 
590
661
  // Return the index just past the balanced close of the element of `tag` that
@@ -7,6 +7,7 @@
7
7
 
8
8
  import MarkdownIt from "markdown-it";
9
9
  import { LOOP_META } from "./context.js";
10
+ import { applyPipeline, fmtAttr, validatePipes } from "./format.js";
10
11
  import {
11
12
  escapeHtml,
12
13
  getPath,
@@ -107,12 +108,15 @@ function resolveBindingRaw(expr, ctx) {
107
108
  function bindingPlugin(mdInstance) {
108
109
  mdInstance.inline.ruler.push("wd_binding", (state, silent) => {
109
110
  if (state.src.charCodeAt(state.pos) !== 0x7b /* { */) return false;
110
- const match = /^\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/.exec(
111
+ // Capture the path plus an optional `| formatter:arg | …` pipe chain. Pipe
112
+ // arguments may not contain `}` (they may be quoted), so the chain runs to
113
+ // the first closing brace.
114
+ const match = /^\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*(?:\|[^}]*)?)\}/.exec(
111
115
  state.src.slice(state.pos)
112
116
  );
113
117
  if (!match) return false;
114
118
  const resolve = state.env?.resolveBinding;
115
- const html = resolve ? resolve(match[1]) : null;
119
+ const html = resolve ? resolve(match[1].trim()) : null;
116
120
  if (typeof html !== "string") return false;
117
121
  if (!silent) {
118
122
  const token = state.push("html_inline", "", 0);
@@ -196,8 +200,13 @@ export function attrTarget(children, i) {
196
200
  * @returns {string | null}
197
201
  */
198
202
  function resolveBindingHtml(expr, ctx) {
199
- const segs = expr.split(".");
203
+ const { path, stages } = validatePipes(expr, ctx);
204
+ const segs = path.split(".");
200
205
  const head = segs[0];
206
+ // Reactive nodes carry the pipe chain in data-wd-fmt; static values fold now.
207
+ const fmt = stages.length ? ` data-wd-fmt="${escapeHtml(fmtAttr(stages))}"` : "";
208
+ /** @param {unknown} v */
209
+ const fold = (v) => (stages.length ? applyPipeline(v, stages) : v);
201
210
 
202
211
  // Per-row meta vars ($index/$number/$first/$last/$count) — only inside a loop.
203
212
  if (LOOP_META[head]) {
@@ -205,19 +214,19 @@ function resolveBindingHtml(expr, ctx) {
205
214
  throw new Error(
206
215
  `"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value.`
207
216
  );
208
- if (ctx.loopItem) return `<span data-wd-each-meta="${LOOP_META[head]}"></span>`; // reactive: filled per row
217
+ if (ctx.loopItem) return `<span data-wd-each-meta="${LOOP_META[head]}"${fmt}></span>`; // reactive: filled per row
209
218
  // static: the value is in scope (injected by staticUnroll); fall through.
210
219
  }
211
220
 
212
221
  if (ctx.loopItem && head === ctx.loopItem) {
213
222
  const rest = segs.slice(1).join(".");
214
- return `<span data-wd-each${rest ? ` data-wd-path="${escapeHtml(rest)}"` : ""}></span>`;
223
+ return `<span data-wd-each${rest ? ` data-wd-path="${escapeHtml(rest)}"` : ""}${fmt}></span>`;
215
224
  }
216
225
 
217
226
  const staticValue = lookupVar(ctx.scope, head);
218
227
  if (staticValue.found) {
219
228
  const resolved = getPath(staticValue.value, segs.slice(1));
220
- return escapeHtml(interpolateLeaf(resolved, expr, ctx));
229
+ return escapeHtml(interpolateLeaf(fold(resolved), expr, ctx));
221
230
  }
222
231
 
223
232
  const key = resolveStateKey(head, ctx);
@@ -226,7 +235,7 @@ function resolveBindingHtml(expr, ctx) {
226
235
  const initial = getPath(ctx.comp.state.get(key), segs.slice(1));
227
236
  const rest = segs.slice(1).join(".");
228
237
  const pathAttr = rest ? ` data-wd-path="${escapeHtml(rest)}"` : "";
229
- return `<span data-wd-bind="${key}"${pathAttr}>${escapeHtml(interpolateLeaf(initial, expr, ctx))}</span>`;
238
+ return `<span data-wd-bind="${key}"${pathAttr}${fmt}>${escapeHtml(interpolateLeaf(fold(initial), expr, ctx))}</span>`;
230
239
  }
231
240
 
232
241
  return null;
@@ -236,6 +236,37 @@ export function compileComputedExpr(raw, ctx) {
236
236
  strings.push(JSON.stringify(literal.slice(1, -1)));
237
237
  return `__WDSTR${strings.length - 1}__`;
238
238
  });
239
+ // Aggregate calls — sum/avg/min/max over a list state, count of a list — are the
240
+ // one allowed "function call". Each `name(listPath[, field])` is compiled to the
241
+ // whitelisted `A("name", S(listKey…), "field")` helper and masked before the
242
+ // generic function-call rejection below runs, so no arbitrary call survives.
243
+ /** @type {string[]} */
244
+ const aggs = [];
245
+ expr = expr.replace(
246
+ /\b(sum|avg|min|max|count)\s*\(\s*([A-Za-z_$][\w$.]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\)/g,
247
+ (_whole, name, listPath, field) => {
248
+ const segs = listPath.split(".");
249
+ if (
250
+ segs.some((/** @type {string} */ s) =>
251
+ ["constructor", "prototype", "__proto__"].includes(s)
252
+ )
253
+ ) {
254
+ throw new Error(`Path "${listPath}" is not allowed in :computed ${name}() (${ctx.file})`);
255
+ }
256
+ const key = resolveStateKey(segs[0], ctx);
257
+ if (!key) {
258
+ throw new Error(
259
+ `:computed ${name}() references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`
260
+ );
261
+ }
262
+ const rest = segs.slice(1).join(".");
263
+ const listCode = `S(${JSON.stringify(key)}${rest ? `,${JSON.stringify(rest)}` : ""})`;
264
+ aggs.push(
265
+ `A(${JSON.stringify(name)},${listCode}${field ? `,${JSON.stringify(field)}` : ""})`
266
+ );
267
+ return `__WDAGG${aggs.length - 1}__`;
268
+ }
269
+ );
239
270
  if (/["'\\`]/.test(expr)) {
240
271
  throw new Error(`Unsupported string syntax in :computed expression "${raw}" (${ctx.file})`);
241
272
  }
@@ -260,7 +291,7 @@ export function compileComputedExpr(raw, ctx) {
260
291
  );
261
292
  }
262
293
  expr = expr.replace(/[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/g, (ref) => {
263
- if (/^__WDSTR\d+__$/.test(ref)) return ref;
294
+ if (/^__WDSTR\d+__$/.test(ref) || /^__WDAGG\d+__$/.test(ref)) return ref;
264
295
  if (["true", "false", "null"].includes(ref)) return ref;
265
296
  const segs = ref.split(".");
266
297
  if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
@@ -277,5 +308,7 @@ export function compileComputedExpr(raw, ctx) {
277
308
  const rest = segs.slice(1).join(".");
278
309
  return `S(${JSON.stringify(key)}${rest ? `,${JSON.stringify(rest)}` : ""})`;
279
310
  });
280
- return expr.replace(/__WDSTR(\d+)__/g, (_, index) => strings[Number(index)]);
311
+ return expr
312
+ .replace(/__WDAGG(\d+)__/g, (_, index) => aggs[Number(index)])
313
+ .replace(/__WDSTR(\d+)__/g, (_, index) => strings[Number(index)]);
281
314
  }
package/src/headers.js CHANGED
@@ -19,6 +19,9 @@
19
19
  * so reactive pages additionally need `script-src 'unsafe-eval'`.
20
20
  * - Inline `<style>` for view transitions — `style-src 'unsafe-inline'`.
21
21
  * - `data:` favicon and remote images — `img-src 'self' data: https:`.
22
+ * - `:video`/`:audio` allow relative or `http(s)` sources — `media-src 'self' https:`.
23
+ * - `:embed` rewrites to exactly the YouTube no-cookie and Vimeo player origins,
24
+ * so `frame-src` pre-authorizes those two hosts (and nothing else).
22
25
  *
23
26
  * Static pages (zero framework JS) still emit inline `<style>` and an inline
24
27
  * `<script type="speculationrules">`, so they keep `'unsafe-inline'`, but they
@@ -42,6 +45,8 @@ const COMMON_CSP_DIRECTIVES = [
42
45
  "default-src 'self'",
43
46
  "style-src 'self' 'unsafe-inline'",
44
47
  "img-src 'self' data: https:",
48
+ "media-src 'self' https:",
49
+ "frame-src https://www.youtube-nocookie.com https://player.vimeo.com",
45
50
  "font-src 'self'",
46
51
  CONNECT_SRC,
47
52
  "object-src 'none'",