@zvndev/darkmown 2.0.0 → 2.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.
Files changed (55) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +31 -7
  4. package/SECURITY.md +4 -4
  5. package/docs/spec-alignment.md +19 -6
  6. package/package.json +5 -2
  7. package/site/_/footer.wd +1 -1
  8. package/site/_/nav.wd +1 -0
  9. package/site/pages/app.wd +1 -1
  10. package/site/pages/blog/zero-js-by-default.md +1 -1
  11. package/site/pages/docs/index.wd +18 -2
  12. package/site/pages/index.js +19 -0
  13. package/site/pages/index.skin +46 -1
  14. package/site/pages/index.wd +7 -3
  15. package/site/pages/playground/index.js +158 -0
  16. package/site/pages/playground/index.skin +128 -0
  17. package/site/pages/playground/index.wd +51 -0
  18. package/site/pages/showcase/index.wd +1 -1
  19. package/src/builder.js +10 -8
  20. package/src/compiler/collections.js +16 -11
  21. package/src/compiler/context.js +22 -4
  22. package/src/compiler/expr-ast.js +296 -0
  23. package/src/compiler/fs-reader.js +25 -0
  24. package/src/compiler/includes.js +25 -14
  25. package/src/compiler/index.js +1 -1
  26. package/src/compiler/loops.js +44 -7
  27. package/src/compiler/page.js +97 -17
  28. package/src/compiler/predicates.js +4 -12
  29. package/src/compiler/reader.js +60 -0
  30. package/src/compiler/state.js +16 -18
  31. package/src/compiler/structure.js +40 -10
  32. package/src/compiler.js +57 -6
  33. package/src/headers.js +15 -13
  34. package/src/index.js +1 -0
  35. package/src/runtime.js +77 -45
  36. package/src/statics.js +6 -7
  37. package/src/templates/blog/site/_/nav.wd +6 -9
  38. package/src/templates/blog/site/pages/index.skin +7 -4
  39. package/src/templates/blog/site/pages/index.wd +2 -3
  40. package/src/templates/dashboard/site/_/nav.wd +6 -9
  41. package/src/templates/dashboard/site/pages/index.skin +7 -4
  42. package/src/templates/dashboard/site/pages/index.wd +2 -3
  43. package/src/templates/landing/site/_/nav.wd +6 -9
  44. package/src/templates/landing/site/pages/index.skin +13 -4
  45. package/src/templates/landing/site/pages/index.wd +22 -9
  46. package/src/templates/starter/site/_/nav.wd +7 -10
  47. package/src/templates/starter/site/pages/about.md +7 -18
  48. package/src/templates/starter/site/pages/about.skin +0 -25
  49. package/src/templates/starter/site/pages/index.skin +10 -6
  50. package/src/templates/starter/site/pages/index.wd +2 -3
  51. package/src/templates/store/site/_/nav.wd +6 -9
  52. package/src/templates/store/site/pages/index.skin +7 -4
  53. package/src/templates/store/site/pages/index.wd +2 -3
  54. package/types/compiler.d.ts +13 -0
  55. package/types/index.d.ts +1 -0
@@ -12,6 +12,7 @@
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
14
  import { at, createScope, LOOP_META, nestedCtx } from "./context.js";
15
+ import { astOf, serializeExpr } from "./expr-ast.js";
15
16
  import { resolveInclude, scopedSkinFor, stampScope } from "./includes.js";
16
17
  import {
17
18
  escapeHtml,
@@ -27,6 +28,14 @@ import { compileWhen, evalPredicate } from "./predicates.js";
27
28
  * @typedef {import("./context.js").Ctx} Ctx
28
29
  */
29
30
 
31
+ // Container names that emit their own semantic landmark element instead of a
32
+ // `<div>`, so scaffolds get real `<nav>`/`<main>` landmarks (accessibility: the
33
+ // skip link targets a genuine main-content region and navigation chrome sits in
34
+ // its own landmark). The name is still added as a class, so existing `.nav`/
35
+ // `.main` skin selectors keep working. Fixed compiler-owned constants — never
36
+ // author-supplied — so no tag-injection surface; classes/ids still escape.
37
+ const SEMANTIC_CONTAINER_TAGS = new Set(["nav", "main"]);
38
+
30
39
  /**
31
40
  * @param {string} line
32
41
  * @param {Ctx} ctx
@@ -36,9 +45,20 @@ import { compileWhen, evalPredicate } from "./predicates.js";
36
45
  export function handleInclude(line, ctx, index) {
37
46
  const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
38
47
  if (!match) throw new Error(`Malformed @include in ${at(ctx, index)}: ${line}`);
39
- const target = resolveInclude(match[1], ctx.file, ctx.context);
48
+ const target = resolveInclude(
49
+ match[1],
50
+ ctx.file,
51
+ ctx.context,
52
+ false,
53
+ at(ctx, index),
54
+ ctx.comp.reader
55
+ );
40
56
  const args = parseIncludeArgs(match[2] || "", ctx);
41
57
  const childScope = createScope(ctx.scope, args);
58
+ // Thread the reactive-nesting depth (and the enclosing loop opener, so a depth
59
+ // error names the right opener) into the include, exactly as `loopItem` is —
60
+ // otherwise a reactive @loop inside the include would start counting from zero
61
+ // and could open a THIRD nested `data-wd-loop` level that silently paints empty.
42
62
  const child = ctx.compileFile(
43
63
  target,
44
64
  ctx.context,
@@ -46,14 +66,16 @@ export function handleInclude(line, ctx, index) {
46
66
  childScope,
47
67
  ctx.comp,
48
68
  ctx.sections,
49
- ctx.loopItem
69
+ ctx.loopItem,
70
+ ctx.reactiveDepth ?? 0,
71
+ ctx.loopOpener ?? null
50
72
  );
51
73
  // An include with its OWN scoped colocated skin stamps just its returned
52
74
  // subtree — so a scoped `.card` in the include can't collide with a `.card`
53
75
  // anywhere else on the page, and the scope never leaks to the include's
54
76
  // siblings (only this child HTML is stamped). The CSS rewrite (builder) uses
55
77
  // the same path-derived id, so attribute and stylesheet line up.
56
- const scopedSkin = scopedSkinFor(target, ctx.context);
78
+ const scopedSkin = scopedSkinFor(target, ctx.context, ctx.comp.reader);
57
79
  return scopedSkin && scopedSkin.scoped ? stampScope(child.html, scopedSkin.scopeId) : child.html;
58
80
  }
59
81
 
@@ -96,20 +118,28 @@ export function handleContainer(header, bodyLines, ctx, index) {
96
118
  let tag = "section";
97
119
  /** @type {string[]} Static classes baked into class="". */
98
120
  const extraClass = [];
99
- /** @type {[string, string][]} Reactive loop-item class bindings (data-wd-each-class). */
121
+ /** @type {[string, any[]][]} Reactive loop-item class bindings (data-wd-each-class): [class, exprAST]. */
100
122
  const eachClasses = [];
101
- /** @type {[string, string][]} Global state-driven class bindings (data-wd-class). */
123
+ /** @type {[string, any[]][]} Global state-driven class bindings (data-wd-class): [class, exprAST]. */
102
124
  const stateClasses = [];
103
125
  let id = "";
104
126
  // Leading tag/name token (anything not starting with . or #). "section" keeps
105
- // the <section> tag; any other name becomes a <div> and also a class.
127
+ // the <section> tag; a whitelisted semantic landmark (`nav`/`main`) emits that
128
+ // real element AND keeps the name as a class hook (so `.nav`/`.main` skins
129
+ // still cascade); any other name becomes a <div> and also a class. The tag is
130
+ // always one of these fixed constants — never author text — so it can't inject.
106
131
  const lead = rest.match(/^([^\s.#]\S*)/);
107
132
  let nameToken = "section";
108
133
  if (lead) {
109
134
  nameToken = lead[1];
110
135
  rest = rest.slice(lead[0].length).trim();
111
136
  }
112
- if (nameToken !== "section") {
137
+ if (nameToken === "section") {
138
+ // default tag, no class
139
+ } else if (SEMANTIC_CONTAINER_TAGS.has(nameToken)) {
140
+ tag = nameToken;
141
+ extraClass.push(nameToken);
142
+ } else {
113
143
  tag = "div";
114
144
  extraClass.push(nameToken);
115
145
  }
@@ -135,8 +165,8 @@ export function handleContainer(header, bodyLines, ctx, index) {
135
165
  const compiled = compileWhen(whenMatch[1].trim(), ctx);
136
166
  if (compiled.static) {
137
167
  if (compiled.value) extraClass.push(cls);
138
- } else if (compiled.item) eachClasses.push([cls, compiled.body]);
139
- else stateClasses.push([cls, compiled.body]);
168
+ } else if (compiled.item) eachClasses.push([cls, astOf(compiled.body)]);
169
+ else stateClasses.push([cls, astOf(compiled.body)]);
140
170
  } else {
141
171
  extraClass.push(cls);
142
172
  }
@@ -306,7 +336,7 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
306
336
  ctx.comp.assets.runtime = true;
307
337
  const truthy = ctx.compileBody(truthyLines, truthyCtx).trim();
308
338
  const falsy = ctx.compileBody(falsyLines, falsyCtx).trim();
309
- const exprAttr = ` data-wd-if-expr="${escapeHtml(compiled.body)}"`;
339
+ const exprAttr = ` data-wd-if-expr="${escapeHtml(serializeExpr(compiled.body))}"`;
310
340
  if (compiled.item) {
311
341
  return `<span data-wd-each-if${exprAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
312
342
  }
package/src/compiler.js CHANGED
@@ -1,7 +1,13 @@
1
1
  // Darkmown's compiler, split into cohesive modules under `src/compiler/`. This
2
- // file is a thin barrel: it re-exports the public API from `src/compiler/index.js`
3
- // so every existing `import … from "./compiler.js"` (builder, tests) keeps
4
- // working unchanged. See `src/compiler/index.js` for the module map.
2
+ // file is the Node-facing barrel: it re-exports the public API from
3
+ // `src/compiler/index.js` so every existing `import … from "./compiler.js"`
4
+ // (builder, tests) keeps working unchanged. It is ALSO the single place the
5
+ // default filesystem reader is injected — `compilePage`/`compileDocument`/
6
+ // `enhanceImages` default their reader to `fsReader()` here, so callers keep
7
+ // their old signatures and the compile stays byte-identical. The fs-free entry
8
+ // point (`compileFromMemory`) needs no default and passes through. Because this
9
+ // module imports `fs-reader.js` (and thus `node:fs`), it is Node-only; the
10
+ // browser bundles `compileFromMemory` from `src/compiler/page.js` directly.
5
11
 
6
12
  // Re-export the public typedefs so existing `import("./compiler.js").Type`
7
13
  // references (e.g. `Assets` in src/builder.js) keep resolving after the split.
@@ -16,12 +22,57 @@
16
22
  * @typedef {import("./compiler/context.js").CompiledPage} CompiledPage
17
23
  */
18
24
 
25
+ import { fsReader } from "./compiler/fs-reader.js";
26
+ import {
27
+ compileDocument as compileDocumentImpl,
28
+ compilePage as compilePageImpl,
29
+ enhanceImages as enhanceImagesImpl
30
+ } from "./compiler/index.js";
31
+
19
32
  export {
20
- compileDocument,
21
- compilePage,
22
- enhanceImages,
33
+ compileFromMemory,
23
34
  escapeHtml,
24
35
  loopKeyOf,
25
36
  parseFrontmatter,
26
37
  stampScope
27
38
  } from "./compiler/index.js";
39
+
40
+ /**
41
+ * Compile a page source file into a full HTML document plus its assets, reading
42
+ * from the real filesystem by default. Pass `options.reader` to compile against
43
+ * a different source (see `compileFromMemory`).
44
+ * @param {string} file Absolute path to the source `.md`/`.wd` file.
45
+ * @param {import("./config.js").Paths} context Resolved project paths.
46
+ * @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader }} [options]
47
+ * @returns {CompiledPage}
48
+ */
49
+ export function compilePage(file, context, options = {}) {
50
+ return compilePageImpl(file, context, { reader: fsReader(), ...options });
51
+ }
52
+
53
+ /**
54
+ * Compile a source file into its body HTML, frontmatter, and assets (no page
55
+ * shell), reading from the real filesystem by default.
56
+ * @param {string} file Absolute path to the source file.
57
+ * @param {import("./config.js").Paths} context Resolved project paths.
58
+ * @param {string[]} [stack] Include stack for cycle detection.
59
+ * @param {Record<string, unknown>} [vars] Initial static scope variables.
60
+ * @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} [collections]
61
+ * @param {import("./compiler/reader.js").Reader} [reader] Source reader.
62
+ * @returns {CompiledDocument}
63
+ */
64
+ export function compileDocument(file, context, stack, vars, collections, reader = fsReader()) {
65
+ return compileDocumentImpl(file, context, stack, vars, collections, reader);
66
+ }
67
+
68
+ /**
69
+ * Harden every `<img>` in an assembled page body, reading intrinsic dimensions
70
+ * from the real filesystem by default.
71
+ * @param {string} html Assembled page body HTML.
72
+ * @param {import("./config.js").Paths} paths Resolved project paths.
73
+ * @param {import("./compiler/reader.js").Reader} [reader] Source reader for image bytes.
74
+ * @returns {string}
75
+ */
76
+ export function enhanceImages(html, paths, reader = fsReader()) {
77
+ return enhanceImagesImpl(html, paths, reader);
78
+ }
package/src/headers.js CHANGED
@@ -23,10 +23,9 @@
23
23
  * raw `<script>` an author writes into `html: true` markdown is therefore
24
24
  * blocked by the shipped CSP — use a colocated `.js` file (same-origin,
25
25
  * covered by `'self'`) or widen `script-src` deliberately.
26
- * - The reactive runtime compiles validated expressions via `new Function`,
27
- * so reactive pages additionally need `script-src 'unsafe-eval'`. That
28
- * keyword cannot be hashed away: it gates eval-family APIs, not inline
29
- * script elements.
26
+ * - The reactive runtime WALKS a compile-time-validated expression AST (see
27
+ * src/compiler/expr-ast.js) rather than building a `new Function`, so reactive
28
+ * pages need NO `'unsafe-eval'`: their CSP is identical to the static one.
30
29
  * - Inline `<style>` for view transitions — `style-src 'unsafe-inline'`.
31
30
  * - `data:` favicon and remote images — `img-src 'self' data: https:`.
32
31
  * - `:video`/`:audio` allow relative or `http(s)` sources — `media-src 'self' https:`.
@@ -36,8 +35,8 @@
36
35
  * authorizes it (and blocks a form exfiltrating to a third-party origin). Apps
37
36
  * posting to a remote backend widen this alongside `connect-src`.
38
37
  *
39
- * Static pages (zero framework JS) share the same hash-based `script-src` but
40
- * never call `new Function`, so their CSP drops `'unsafe-eval'`.
38
+ * Static pages (zero framework JS) and reactive pages now share the same
39
+ * eval-free `script-src` — no page needs `'unsafe-eval'`.
41
40
  */
42
41
 
43
42
  import { createHash } from "node:crypto";
@@ -102,17 +101,20 @@ const COMMON_CSP_DIRECTIVES = [
102
101
  ];
103
102
 
104
103
  /**
105
- * Relaxed CSP for reactive pages: `new Function` needs `'unsafe-eval'`; the
106
- * inline speculationrules block rides on the shared hash source, so no
107
- * `'unsafe-inline'`.
104
+ * CSP for reactive pages. Since 2.1 the runtime WALKS a compile-time-validated
105
+ * expression AST instead of building a `new Function`, so reactive pages no longer
106
+ * need `'unsafe-eval'` — this policy is byte-identical to the static one. The only
107
+ * script source is same-origin `/__wd/runtime.js` (plus the hashed speculationrules
108
+ * block); no `'unsafe-inline'`, no `'unsafe-eval'`.
108
109
  * @type {string}
109
110
  */
110
- export const REACTIVE_CSP = [`${SCRIPT_SRC} 'unsafe-eval'`, ...COMMON_CSP_DIRECTIVES].join("; ");
111
+ export const REACTIVE_CSP = [SCRIPT_SRC, ...COMMON_CSP_DIRECTIVES].join("; ");
111
112
 
112
113
  /**
113
- * Stricter CSP for static pages: no runtime means no `new Function`, so
114
- * `'unsafe-eval'` is dropped too. Inline `<style>` (view transitions) keeps
115
- * `'unsafe-inline'` on style-src only.
114
+ * CSP for static pages (zero framework JS). Identical to the reactive policy now
115
+ * that neither variant evals — kept as a distinct export so the per-route emit and
116
+ * the `securityHeaders()` callers stay explicit about intent. Inline `<style>`
117
+ * (view transitions) keeps `'unsafe-inline'` on style-src only.
116
118
  * @type {string}
117
119
  */
118
120
  export const STATIC_CSP = [SCRIPT_SRC, ...COMMON_CSP_DIRECTIVES].join("; ");
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ export { buildSite } from "./builder.js";
7
7
 
8
8
  export {
9
9
  compileDocument,
10
+ compileFromMemory,
10
11
  compilePage,
11
12
  escapeHtml,
12
13
  loopKeyOf,
package/src/runtime.js CHANGED
@@ -213,12 +213,12 @@ async function httpJson(url, init) {
213
213
  const computedDefs = [...document.querySelectorAll("[data-wd-computed]")].map((node) => ({
214
214
  key: node.getAttribute("data-wd-computed-key") || "",
215
215
  expr: node.getAttribute("data-wd-computed-expr") || "",
216
- evaluate: new Function("S", "A", `return (${node.getAttribute("data-wd-computed-expr")});`)
216
+ ast: JSON.parse(node.getAttribute("data-wd-computed-expr") || "null")
217
217
  }));
218
218
 
219
219
  /**
220
220
  * Log an expression failure when `window.wd.debug` is enabled.
221
- * @param {string} expr
221
+ * @param {unknown} expr
222
222
  * @param {unknown} error
223
223
  * @returns {void}
224
224
  */
@@ -232,11 +232,9 @@ function warn(expr, error) {
232
232
  * @returns {void}
233
233
  */
234
234
  function recompute() {
235
- /** @param {string} key @param {string | null} [path] */
236
- const read = (key, path) => getPath(state[key], path ?? null);
237
235
  for (const def of computedDefs) {
238
236
  try {
239
- state[def.key] = def.evaluate(read, AGG);
237
+ state[def.key] = evalAst(def.ast, undefined);
240
238
  } catch (error) {
241
239
  state[def.key] = undefined;
242
240
  warn(def.expr, error);
@@ -246,26 +244,80 @@ function recompute() {
246
244
 
247
245
  /** @param {unknown} a @param {unknown} b @returns {boolean} */
248
246
  const containsFn = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
249
- /** @type {Map<string, Function>} */
250
- const predicateCache = new Map();
247
+
251
248
  /**
252
- * @param {string} body
253
- * @returns {Function}
249
+ * Evaluate a compact expression AST (see src/compiler/expr-ast.js) against the
250
+ * live state and an optional loop `item`. The compiler emits this serialized AST
251
+ * into the `data-wd-*` attributes; the runtime WALKS it — no `new Function`, so
252
+ * reactive pages run under a strict CSP with no `'unsafe-eval'`. The op vocabulary
253
+ * is closed: every node tag is one of the readers (L/S/I/C/A), a unary (!/u-), or
254
+ * a binary operator, and an unknown tag is a hard error, never a fallthrough.
255
+ * Prototype-pollution segments are rejected by getPath, exactly as before.
256
+ * @param {any[]} node
257
+ * @param {any} item Loop row for I(); undefined otherwise.
258
+ * @returns {any}
254
259
  */
255
- function loopPredicate(body) {
256
- let fn = predicateCache.get(body);
257
- if (!fn) {
258
- fn = new Function("I", "S", "C", `return (${body});`);
259
- predicateCache.set(body, fn);
260
- }
261
- return fn;
260
+ function evalAst(node, item) {
261
+ /** @param {any[]} n @returns {any} */
262
+ const ev = (n) => {
263
+ switch (n[0]) {
264
+ case "L": return n[1];
265
+ case "S": return getPath(state[n[1]], n[2] ?? null);
266
+ case "I": return getPath(item, n[1] ?? null);
267
+ case "C": return containsFn(ev(n[1]), ev(n[2]));
268
+ case "A": return AGG(n[1], ev(n[2]), n[3]);
269
+ case "!": return !ev(n[1]);
270
+ case "u-": return -ev(n[1]);
271
+ case "&&": return ev(n[1]) && ev(n[2]);
272
+ case "||": return ev(n[1]) || ev(n[2]);
273
+ // biome-ignore lint/suspicious/noDoubleEquals: loose equality preserves prior `new Function` semantics exactly.
274
+ case "==": return ev(n[1]) == ev(n[2]);
275
+ // biome-ignore lint/suspicious/noDoubleEquals: loose inequality preserves prior `new Function` semantics exactly.
276
+ case "!=": return ev(n[1]) != ev(n[2]);
277
+ case ">": return ev(n[1]) > ev(n[2]);
278
+ case "<": return ev(n[1]) < ev(n[2]);
279
+ case ">=": return ev(n[1]) >= ev(n[2]);
280
+ case "<=": return ev(n[1]) <= ev(n[2]);
281
+ case "+": return ev(n[1]) + ev(n[2]);
282
+ case "-": return ev(n[1]) - ev(n[2]);
283
+ case "*": return ev(n[1]) * ev(n[2]);
284
+ case "/": return ev(n[1]) / ev(n[2]);
285
+ case "%": return ev(n[1]) % ev(n[2]);
286
+ }
287
+ throw new Error(`wd: unknown op ${n[0]}`);
288
+ };
289
+ return ev(node);
290
+ }
291
+
292
+ /** @type {Map<string, any[]>} Parsed-AST cache keyed by the raw attribute JSON. */
293
+ const astCache = new Map();
294
+ /** @param {string} raw @returns {any[]} */
295
+ function parseAst(raw) {
296
+ const cached = astCache.get(raw);
297
+ if (cached) return cached;
298
+ const ast = JSON.parse(raw);
299
+ astCache.set(raw, ast);
300
+ return ast;
301
+ }
302
+
303
+ /**
304
+ * Evaluate an expression AST to a boolean, swallowing failures (logs under
305
+ * `wd.debug`). Shared by reactive classes (`.class when`), loop `where`, and
306
+ * expression conditionals (`:if a > b`).
307
+ * @param {any[]} ast
308
+ * @param {any} item Loop row for per-row predicates, else undefined.
309
+ * @returns {boolean}
310
+ */
311
+ function evalPredicate(ast, item) {
312
+ try { return Boolean(evalAst(ast, item)); }
313
+ catch (error) { warn(ast, error); return false; }
262
314
  }
263
315
 
264
316
  /**
265
317
  * Toggle reactive `.class when <predicate>` bindings on an element. `raw` is the
266
- * JSON `[[name, body], …]` from data-wd-class (global, item undefined) or
267
- * data-wd-each-class (per loop row); each body is the same I()/S()/C()
268
- * expression the loop `where` uses.
318
+ * JSON `[[name, ast], …]` from data-wd-class (global, item undefined) or
319
+ * data-wd-each-class (per loop row); each ast is the same expression AST the loop
320
+ * `where` uses.
269
321
  * @param {Element} el
270
322
  * @param {string | null} raw
271
323
  * @param {any} item Loop item for each-class; undefined for a global class.
@@ -273,23 +325,10 @@ function loopPredicate(body) {
273
325
  */
274
326
  function classToggle(el, raw, item) {
275
327
  if (!raw) return;
276
- /** @type {[string, string][]} */
328
+ /** @type {[string, any[]][]} */
277
329
  let pairs;
278
330
  try { pairs = JSON.parse(raw); } catch { return; }
279
- for (const [name, body] of pairs) el.classList.toggle(name, evalPredicate(body, item));
280
- }
281
-
282
- /**
283
- * Evaluate a compiled whitelist predicate body (over I()/S()/C()) to a boolean.
284
- * Shared by reactive classes (`.class when`) and expression conditionals
285
- * (`:if a > b`). `item` is the loop row for per-row predicates, else undefined.
286
- * @param {string} body
287
- * @param {any} item
288
- * @returns {boolean}
289
- */
290
- function evalPredicate(body, item) {
291
- try { return Boolean(loopPredicate(body)((/** @type {string} */ p) => getPath(item, p), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn)); }
292
- catch (error) { warn(body, error); return false; }
331
+ for (const [name, ast] of pairs) el.classList.toggle(name, evalPredicate(ast, item));
293
332
  }
294
333
 
295
334
  /**
@@ -328,7 +367,7 @@ function fillItem(node, item, meta = {}) {
328
367
  if (skip(region)) continue;
329
368
  const expr = region.getAttribute("data-wd-if-expr");
330
369
  const m = region.getAttribute("data-wd-meta");
331
- const value = expr ? evalPredicate(expr, item) : (m ? meta[m] : getPath(item, region.getAttribute("data-wd-path")));
370
+ const value = expr ? evalPredicate(parseAst(expr), item) : (m ? meta[m] : getPath(item, region.getAttribute("data-wd-path")));
332
371
  const output = region.querySelector("[data-wd-each-if-out]");
333
372
  if (!output) continue;
334
373
  const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]"));
@@ -414,7 +453,7 @@ function pipeline(list, region) {
414
453
  */
415
454
  function renderIf(node) {
416
455
  const expr = node.getAttribute("data-wd-if-expr");
417
- const value = expr ? evalPredicate(expr, undefined) : getPath(state[node.getAttribute("data-wd-if") || ""], node.getAttribute("data-wd-path"));
456
+ const value = expr ? evalPredicate(parseAst(expr), undefined) : getPath(state[node.getAttribute("data-wd-if") || ""], node.getAttribute("data-wd-path"));
418
457
  const active = String(Boolean(value));
419
458
  if (node.getAttribute("data-wd-if-active") === active) return;
420
459
  node.setAttribute("data-wd-if-active", active);
@@ -441,15 +480,8 @@ function reconcile(region, rows) {
441
480
  let list = Array.isArray(rows) ? rows.slice() : [];
442
481
  const where = region.getAttribute("data-wd-loop-where");
443
482
  if (where) {
444
- const predicate = loopPredicate(where);
445
- list = list.filter((/** @type {any} */ item) => {
446
- try {
447
- return predicate((/** @type {string | null} */ path) => getPath(item, path), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn);
448
- } catch (error) {
449
- warn(where, error);
450
- return false;
451
- }
452
- });
483
+ const ast = parseAst(where);
484
+ list = list.filter((/** @type {any} */ item) => evalPredicate(ast, item));
453
485
  }
454
486
  list = pipeline(list, region);
455
487
 
package/src/statics.js CHANGED
@@ -5,13 +5,12 @@ import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
5
5
 
6
6
  /**
7
7
  * The security headers (including the right CSP variant) for an HTML response.
8
- * Mirrors what production ships: a route the build marked reactive gets the
9
- * relaxed CSP (`'unsafe-eval'` for `new Function`); a static route gets the
10
- * stricter, eval-free CSP — so a local preview matches the per-route `_headers`
11
- * / `vercel.json` the same build writes instead of blanket-relaxing every page.
12
- * Anything not found in `routes.json` (an unknown route, a 404, a missing
13
- * manifest) falls back to the reactive CSP, exactly like the production
14
- * `_headers` catch-all block.
8
+ * Mirrors what production ships. Since 2.1 both variants are eval-free (the
9
+ * reactive runtime walks a validated AST, not `new Function`), so a reactive and a
10
+ * static route resolve to the same CSP — the per-route selection is kept for parity
11
+ * with the `_headers` / `vercel.json` structure the same build writes. Anything not
12
+ * found in `routes.json` (an unknown route, a 404, a missing manifest) falls back
13
+ * to the reactive CSP, exactly like the production `_headers` catch-all block.
15
14
  * @param {string} distRoot Absolute path to the build output directory.
16
15
  * @param {string} url Request URL (may include a query string).
17
16
  * @returns {Record<string, string>}
@@ -1,10 +1,7 @@
1
- ---
2
- html: true
3
- ---
1
+ ::: nav
2
+ **My Blog**
4
3
 
5
- <nav>
6
- <strong>My Blog</strong>
7
- <span class="links">
8
- <a href="/">Posts</a>
9
- </span>
10
- </nav>
4
+ ::: links
5
+ [Posts](/)
6
+ :::
7
+ :::
@@ -27,12 +27,12 @@ page
27
27
  line-height 1.6
28
28
  -webkit-font-smoothing antialiased
29
29
 
30
- main
30
+ .main
31
31
  max-width 720px
32
32
  margin 0 auto
33
33
  padding 2.5rem 1.5rem
34
34
 
35
- nav
35
+ .nav
36
36
  display flex
37
37
  align-items center
38
38
  justify-content space-between
@@ -40,10 +40,13 @@ nav
40
40
  margin 0 auto
41
41
  padding 1.25rem 1.5rem
42
42
 
43
- nav strong
43
+ .nav p
44
+ margin 0
45
+
46
+ .nav strong
44
47
  font-size 1.1rem
45
48
 
46
- nav a
49
+ .nav a
47
50
  color $accent
48
51
  text-decoration none
49
52
 
@@ -1,5 +1,4 @@
1
1
  ---
2
- html: true
3
2
  title: My Blog
4
3
  description: A markdown-native blog built with Darkmown.
5
4
  site_url: https://example.com
@@ -8,7 +7,7 @@ transitions: true
8
7
 
9
8
  @include /nav.wd
10
9
 
11
- <main>
10
+ ::: main
12
11
 
13
12
  # My Blog
14
13
 
@@ -28,4 +27,4 @@ config, zero JavaScript; each post's frontmatter is the data.
28
27
  No posts yet — add a `.md` file to `site/pages/posts/`.
29
28
  @endloop
30
29
 
31
- </main>
30
+ :::
@@ -1,10 +1,7 @@
1
- ---
2
- html: true
3
- ---
1
+ ::: nav
2
+ **Dashboard**
4
3
 
5
- <nav>
6
- <strong>Dashboard</strong>
7
- <span class="links">
8
- <a href="/">Overview</a>
9
- </span>
10
- </nav>
4
+ ::: links
5
+ [Overview](/)
6
+ :::
7
+ :::
@@ -18,12 +18,12 @@ page
18
18
  line-height 1.55
19
19
  -webkit-font-smoothing antialiased
20
20
 
21
- main
21
+ .main
22
22
  max-width 880px
23
23
  margin 0 auto
24
24
  padding 2rem 1.5rem
25
25
 
26
- nav
26
+ .nav
27
27
  display flex
28
28
  align-items center
29
29
  justify-content space-between
@@ -31,10 +31,13 @@ nav
31
31
  margin 0 auto
32
32
  padding 1.25rem 1.5rem
33
33
 
34
- nav strong
34
+ .nav p
35
+ margin 0
36
+
37
+ .nav strong
35
38
  font-size 1.1rem
36
39
 
37
- nav a
40
+ .nav a
38
41
  color $accent
39
42
  text-decoration none
40
43
 
@@ -1,5 +1,4 @@
1
1
  ---
2
- html: true
3
2
  title: Dashboard — built with Darkmown
4
3
  description: A live dashboard — stat cards fetched from a serverless function, on an otherwise static page.
5
4
  transitions: true
@@ -7,7 +6,7 @@ transitions: true
7
6
 
8
7
  @include /nav.wd
9
8
 
10
- <main>
9
+ ::: main
11
10
 
12
11
  ::: section #dash
13
12
 
@@ -73,4 +72,4 @@ The numbers come from `api/metrics.js` — a serverless function that runs in
73
72
 
74
73
  :::
75
74
 
76
- </main>
75
+ :::
@@ -1,10 +1,7 @@
1
- ---
2
- html: true
3
- ---
1
+ ::: nav
2
+ **Acme**
4
3
 
5
- <nav>
6
- <strong>Acme</strong>
7
- <span class="links">
8
- <a href="#start">Get started</a>
9
- </span>
10
- </nav>
4
+ ::: links
5
+ [Get started](#start)
6
+ :::
7
+ :::