@zvndev/darkmown 0.19.0 → 0.20.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.
@@ -0,0 +1,281 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Predicate / expression compilers: the compile-time-validated whitelists behind
3
+ // `@loop … where`, `::: … .class when`, `:if a <op> b`, and `:computed`. Every
4
+ // operand is mapped through a fixed grammar to safe JS over the `I()`/`S()`/`C()`
5
+ // readers — no identifier survives un-mapped, so no raw user content is eval'd.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ import { getPath, lookupVar, resolveStateKey } from "./interpolation.js";
9
+
10
+ /**
11
+ * @typedef {import("./context.js").Ctx} Ctx
12
+ * @typedef {import("./context.js").Predicate} Predicate
13
+ */
14
+
15
+ // Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
16
+ // Conditions (operand <op> operand) join with `and`/`or`; operands are loop-item
17
+ // paths, declared :state, numbers, or strings. No identifiers survive un-mapped.
18
+ /**
19
+ * @param {string} raw
20
+ * @param {string} itemName
21
+ * @param {Ctx} ctx
22
+ * @returns {Predicate}
23
+ */
24
+ export function compilePredicate(raw, itemName, ctx) {
25
+ const parts = raw.split(/\s+(and|or)\s+/i);
26
+ /** @type {string[]} */
27
+ const pieces = [];
28
+ let refsState = false;
29
+ for (let i = 0; i < parts.length; i++) {
30
+ if (i % 2 === 1) {
31
+ pieces.push(parts[i].toLowerCase() === "and" ? "&&" : "||");
32
+ continue;
33
+ }
34
+ const cond = compileCondition(parts[i].trim(), itemName, ctx);
35
+ refsState = refsState || cond.usesState;
36
+ pieces.push(`(${cond.expr})`);
37
+ }
38
+ return { body: pieces.join(" "), refsState };
39
+ }
40
+
41
+ /**
42
+ * @param {string} cond
43
+ * @param {string} itemName
44
+ * @param {Ctx} ctx
45
+ * @returns {{ expr: string, usesState: boolean }}
46
+ */
47
+ function compileCondition(cond, itemName, ctx) {
48
+ const m = cond.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
49
+ if (!m)
50
+ throw new Error(
51
+ `Malformed where-condition "${cond}" in ${ctx.file}. Use: ${itemName}.field contains state, or ${itemName}.field <op> value.`
52
+ );
53
+ const left = compileOperand(m[1].trim(), itemName, ctx);
54
+ const right = compileOperand(m[3].trim(), itemName, ctx);
55
+ const usesState = left.usesState || right.usesState;
56
+ const op = m[2].toLowerCase();
57
+ if (op === "contains") return { expr: `C(${left.code}, ${right.code})`, usesState };
58
+ return { expr: `${left.code} ${op} ${right.code}`, usesState };
59
+ }
60
+
61
+ /**
62
+ * @param {string} tok
63
+ * @param {string} itemName
64
+ * @param {Ctx} ctx
65
+ * @returns {{ code: string, usesState: boolean }}
66
+ */
67
+ function compileOperand(tok, itemName, ctx) {
68
+ if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok))
69
+ return { code: JSON.stringify(tok.slice(1, -1)), usesState: false };
70
+ if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, usesState: false };
71
+ if (["true", "false", "null"].includes(tok)) return { code: tok, usesState: false };
72
+ if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
73
+ throw new Error(
74
+ `Unsupported operand "${tok}" in @loop where (${ctx.file}). Use ${itemName}.field, a :state name, a number, or a "string".`
75
+ );
76
+ }
77
+ const segs = tok.split(".");
78
+ if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
79
+ throw new Error(`Path "${tok}" is not allowed in @loop where (${ctx.file})`);
80
+ }
81
+ if (segs[0] === itemName)
82
+ return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, usesState: false };
83
+ const key = resolveStateKey(segs[0], ctx);
84
+ if (!key) {
85
+ throw new Error(
86
+ `@loop where references unknown name "${segs[0]}" in ${ctx.file}. Use the loop item (${itemName}.field) or a declared :state.`
87
+ );
88
+ }
89
+ const rest = segs.slice(1).join(".");
90
+ return {
91
+ code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`,
92
+ usesState: true
93
+ };
94
+ }
95
+
96
+ /** @param {unknown} a @param {unknown} b @returns {boolean} */
97
+ const containsHelper = (a, b) =>
98
+ String(a ?? "")
99
+ .toLowerCase()
100
+ .includes(String(b ?? "").toLowerCase());
101
+
102
+ /**
103
+ * Evaluate a compiled predicate against a row at build time.
104
+ * @param {string} body
105
+ * @param {unknown} item
106
+ * @param {Ctx} ctx
107
+ * @returns {boolean}
108
+ */
109
+ export function evalPredicate(body, item, ctx) {
110
+ try {
111
+ /** @param {string} [p] */
112
+ const I = (p) => getPath(item, p ? p.split(".") : []);
113
+ /** @param {string} k @param {string} [r] */
114
+ const S = (k, r) => getPath(ctx.comp.state.get(k), r ? r.split(".") : []);
115
+ return Boolean(new Function("I", "S", "C", `return (${body});`)(I, S, containsHelper));
116
+ } catch {
117
+ console.warn(
118
+ `@loop where predicate "${body}" in ${ctx.file} could not be evaluated at build time; treating the row as excluded. Check the condition.`
119
+ );
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Compile one operand of a `::: … .class when <predicate>` expression. Like the
126
+ * `@loop where` operand, but also folds a static-scope value (a loop-unrolled
127
+ * item field or include arg) to its build-time literal, so a fully-static
128
+ * predicate can be evaluated at compile time. Mirrors `:if`'s scope→item→state
129
+ * resolution order.
130
+ * @param {string} tok
131
+ * @param {Ctx} ctx
132
+ * @param {string} what Directive label for error messages.
133
+ * @returns {{ code: string, state: boolean, item: boolean }}
134
+ */
135
+ function compileWhenOperand(tok, ctx, what) {
136
+ if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok))
137
+ return { code: JSON.stringify(tok.slice(1, -1)), state: false, item: false };
138
+ if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, state: false, item: false };
139
+ if (["true", "false", "null"].includes(tok)) return { code: tok, state: false, item: false };
140
+ if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
141
+ throw new Error(
142
+ `Unsupported operand "${tok}" in ${what} (${ctx.file}). Use item.field, a :state name, a number, or a "string".`
143
+ );
144
+ }
145
+ const segs = tok.split(".");
146
+ if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
147
+ throw new Error(`Path "${tok}" is not allowed in ${what} (${ctx.file})`);
148
+ }
149
+ const scoped = lookupVar(ctx.scope, segs[0]);
150
+ if (scoped.found)
151
+ return {
152
+ code: JSON.stringify(getPath(scoped.value, segs.slice(1)) ?? null),
153
+ state: false,
154
+ item: false
155
+ };
156
+ if (ctx.loopItem && segs[0] === ctx.loopItem)
157
+ return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, state: false, item: true };
158
+ const key = resolveStateKey(segs[0], ctx);
159
+ if (!key)
160
+ throw new Error(
161
+ `${what} references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`
162
+ );
163
+ const rest = segs.slice(1).join(".");
164
+ return {
165
+ code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`,
166
+ state: true,
167
+ item: false
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Compile a `::: … .class when <predicate>`. Allows `:if`-style bare truthy
173
+ * paths and `where`-style `left <op> right` conditions joined by `and`/`or`.
174
+ * Folds to a static verdict when every operand is build-known, else returns a
175
+ * runtime body over I()/S()/C() plus whether it reads the reactive loop item
176
+ * (which decides data-wd-each-class vs the global data-wd-class).
177
+ * @param {string} raw
178
+ * @param {Ctx} ctx
179
+ * @param {string} [what] Directive label for error messages.
180
+ * @returns {{ static: true, value: boolean } | { static: false, body: string, item: boolean }}
181
+ */
182
+ export function compileWhen(raw, ctx, what = '"::: … when"') {
183
+ const parts = raw.split(/\s+(and|or)\s+/i);
184
+ /** @type {string[]} */
185
+ const pieces = [];
186
+ let usesState = false;
187
+ let usesItem = false;
188
+ for (let i = 0; i < parts.length; i++) {
189
+ if (i % 2 === 1) {
190
+ pieces.push(parts[i].toLowerCase() === "and" ? "&&" : "||");
191
+ continue;
192
+ }
193
+ let seg = parts[i].trim();
194
+ // Optional leading `not` negates the whole sub-condition.
195
+ let negate = false;
196
+ if (/^not\s+/i.test(seg)) {
197
+ negate = true;
198
+ seg = seg.replace(/^not\s+/i, "").trim();
199
+ }
200
+ const opMatch = seg.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
201
+ let expr;
202
+ if (opMatch) {
203
+ const left = compileWhenOperand(opMatch[1].trim(), ctx, what);
204
+ const right = compileWhenOperand(opMatch[3].trim(), ctx, what);
205
+ usesState = usesState || left.state || right.state;
206
+ usesItem = usesItem || left.item || right.item;
207
+ const op = opMatch[2].toLowerCase();
208
+ expr =
209
+ op === "contains" ? `C(${left.code}, ${right.code})` : `${left.code} ${op} ${right.code}`;
210
+ } else {
211
+ const only = compileWhenOperand(seg, ctx, what);
212
+ usesState = usesState || only.state;
213
+ usesItem = usesItem || only.item;
214
+ expr = only.code;
215
+ }
216
+ pieces.push(negate ? `(!(${expr}))` : `(${expr})`);
217
+ }
218
+ const body = pieces.join(" ");
219
+ if (!usesState && !usesItem) return { static: true, value: evalPredicate(body, undefined, ctx) };
220
+ return { static: false, body, item: usesItem };
221
+ }
222
+
223
+ /**
224
+ * Compile a `:computed` expression into safe JS over the `S(key, path)` reader.
225
+ * @param {string} raw
226
+ * @param {Ctx} ctx
227
+ * @returns {string}
228
+ */
229
+ export function compileComputedExpr(raw, ctx) {
230
+ /** @type {string[]} */
231
+ const strings = [];
232
+ let expr = raw.replace(/"[^"\\]*"|'[^'\\]*'/g, (literal) => {
233
+ // JSON.stringify fully escapes embedded ", \, etc. so the re-inserted literal
234
+ // (line below) is an inert JS string and cannot terminate early to smuggle in
235
+ // live code. Mirrors the SAFE @loop where path (compileOperand).
236
+ strings.push(JSON.stringify(literal.slice(1, -1)));
237
+ return `__WDSTR${strings.length - 1}__`;
238
+ });
239
+ if (/["'\\`]/.test(expr)) {
240
+ throw new Error(`Unsupported string syntax in :computed expression "${raw}" (${ctx.file})`);
241
+ }
242
+ if (!/^[\w$.\s+\-*/%()<>=!&|]*$/.test(expr)) {
243
+ throw new Error(
244
+ `Unsupported syntax in :computed expression "${raw}" (${ctx.file}). Allowed: state names, numbers, strings, + - * / % ( ), comparisons, && || !.`
245
+ );
246
+ }
247
+ if (/(^|[^=!<>])=(?!=)/.test(expr)) {
248
+ throw new Error(`Assignment is not allowed in :computed expressions ("${raw}" in ${ctx.file})`);
249
+ }
250
+ // Reject function-call syntax: a `(` that directly follows an identifier, a
251
+ // string literal, or a closing `)` (e.g. `x()`, `x.valueOf()`, `(a)(b)`). Only
252
+ // grouping parens — where `(` follows an operator or starts the expression —
253
+ // survive. Without this, `x.valueOf()` compiled to `S("x","valueOf")()`, a live
254
+ // call; it is inert under the trusted-author/JSON-state model but contradicts the
255
+ // SECURITY.md guarantee that function calls are compile errors. Runs BEFORE the
256
+ // identifier→S() mapping so it never trips on the emitted helper calls.
257
+ if (/[\w$)]\s*\(/.test(expr)) {
258
+ throw new Error(
259
+ `Function calls are not allowed in :computed expressions ("${raw}" in ${ctx.file})`
260
+ );
261
+ }
262
+ expr = expr.replace(/[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/g, (ref) => {
263
+ if (/^__WDSTR\d+__$/.test(ref)) return ref;
264
+ if (["true", "false", "null"].includes(ref)) return ref;
265
+ const segs = ref.split(".");
266
+ if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
267
+ throw new Error(
268
+ `Path segment "${ref}" is not allowed in :computed expressions (${ctx.file})`
269
+ );
270
+ }
271
+ const key = resolveStateKey(segs[0], ctx);
272
+ if (!key) {
273
+ throw new Error(
274
+ `:computed references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`
275
+ );
276
+ }
277
+ const rest = segs.slice(1).join(".");
278
+ return `S(${JSON.stringify(key)}${rest ? `,${JSON.stringify(rest)}` : ""})`;
279
+ });
280
+ return expr.replace(/__WDSTR(\d+)__/g, (_, index) => strings[Number(index)]);
281
+ }