@zvndev/darkmown 0.18.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.
- package/CHANGELOG.md +22 -0
- package/README.md +60 -3
- package/SECURITY.md +39 -4
- package/docs/spec-alignment.md +9 -0
- package/package.json +23 -5
- package/site/_/nav.wd +1 -0
- package/site/pages/loops.wd +22 -0
- package/site/pages/theme.skin +53 -0
- package/site/pages/theme.wd +58 -0
- package/src/builder.js +66 -10
- package/src/cli.js +216 -77
- package/src/compiler/body.js +432 -0
- package/src/compiler/context.js +160 -0
- package/src/compiler/directives.js +1149 -0
- package/src/compiler/frontmatter.js +117 -0
- package/src/compiler/includes.js +116 -0
- package/src/compiler/index.js +22 -0
- package/src/compiler/interpolation.js +189 -0
- package/src/compiler/loops.js +644 -0
- package/src/compiler/markdown.js +233 -0
- package/src/compiler/page.js +267 -0
- package/src/compiler/predicates.js +281 -0
- package/src/compiler.js +26 -2753
- package/src/dev.js +3 -1
- package/src/headers.js +93 -0
- package/src/index.js +22 -0
- package/src/router.js +3 -1
- package/src/runtime.js +83 -59
- package/src/scaffold.js +158 -131
- package/src/skin.js +62 -3
- package/src/statics.js +19 -2
- package/site/_/og.png +0 -0
|
@@ -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
|
+
}
|