@zvndev/darkmown 0.20.0 → 1.1.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 +29 -0
- package/README.md +125 -5
- package/docs/spec-alignment.md +23 -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 +72 -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 +183 -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 +89 -3
- 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 +36 -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
|
@@ -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,17 @@ export function safeScriptJson(value) {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
|
+
* Parse a `:state`/`:store`/`:theme` value: quoted string, boolean, null, number,
|
|
123
|
+
* or JSON literal — falling back to the bare string. Multi-line array/object
|
|
124
|
+
* literals are joined into one line before reaching here (see `joinValueDirective`
|
|
125
|
+
* in body.js), so a value that opens a `[`/`{` but won't parse is genuinely
|
|
126
|
+
* unbalanced (a missing `]`/`}`) and throws, rather than being stored verbatim as
|
|
127
|
+
* the partial string `"["` — the silent-corruption footgun this guard replaces.
|
|
122
128
|
* @param {string} raw
|
|
129
|
+
* @param {string} [where] `file:line` for the error message.
|
|
123
130
|
* @returns {unknown}
|
|
124
131
|
*/
|
|
125
|
-
export function parseStateValue(raw) {
|
|
132
|
+
export function parseStateValue(raw, where) {
|
|
126
133
|
const value = raw.trim();
|
|
127
134
|
if (/^["'].*["']$/.test(value)) return stripQuotes(value);
|
|
128
135
|
if (value === "true") return true;
|
|
@@ -132,6 +139,17 @@ export function parseStateValue(raw) {
|
|
|
132
139
|
try {
|
|
133
140
|
return JSON.parse(value);
|
|
134
141
|
} catch {
|
|
142
|
+
if (/^[[{]/.test(value)) {
|
|
143
|
+
const open = value[0];
|
|
144
|
+
const close = open === "[" ? "]" : "}";
|
|
145
|
+
const shown = value.length > 60 ? `${value.slice(0, 57)}…` : value;
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Unbalanced JSON value "${shown}"${where ? ` in ${where}` : ""}: this opens "${open}" but ` +
|
|
148
|
+
`never closes it. :state/:store/:theme accept a multi-line array/object, but the literal ` +
|
|
149
|
+
`must balance (a "${close}" is missing) with no blank lines inside it. ` +
|
|
150
|
+
`Quote it for literal bracket text (e.g. = "[draft]").`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
135
153
|
return value;
|
|
136
154
|
}
|
|
137
155
|
}
|
|
@@ -187,3 +205,20 @@ export function escapeHtml(value) {
|
|
|
187
205
|
.replaceAll('"', """)
|
|
188
206
|
.replaceAll("'", "'");
|
|
189
207
|
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Reverse {@link escapeHtml}. Used by the loop initial-paint fill to read a
|
|
211
|
+
* `data-wd-fmt` attribute (HTML-escaped JSON) back into pipe stages at build
|
|
212
|
+
* time — the browser does the same decode for the runtime via getAttribute.
|
|
213
|
+
* `&` is decoded last so `&lt;` round-trips to `<`, not `<`.
|
|
214
|
+
* @param {string} value
|
|
215
|
+
* @returns {string}
|
|
216
|
+
*/
|
|
217
|
+
export function unescapeHtml(value) {
|
|
218
|
+
return String(value)
|
|
219
|
+
.replaceAll("<", "<")
|
|
220
|
+
.replaceAll(">", ">")
|
|
221
|
+
.replaceAll(""", '"')
|
|
222
|
+
.replaceAll("'", "'")
|
|
223
|
+
.replaceAll("&", "&");
|
|
224
|
+
}
|
package/src/compiler/loops.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
if (
|
|
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(
|
|
524
|
-
|
|
525
|
-
|
|
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(
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
package/src/compiler/markdown.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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'",
|