@zvndev/darkmown 2.0.1 → 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.
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>}
@@ -14,6 +14,19 @@ export function compilePage(file: string, context: Paths): CompiledPage;
14
14
  * @returns {CompiledDocument}
15
15
  */
16
16
  export function compileDocument(file: string, context: Paths, stack?: string[], vars?: Record<string, unknown>): CompiledDocument;
17
+ /**
18
+ * Compile a page from an in-memory map of project-relative path → source, with
19
+ * no filesystem access. `entryPath` is the project-relative path of the page to
20
+ * compile; includes and `@loop` data resolve against the same map.
21
+ * @param {Record<string, string> | Map<string, string>} files
22
+ * @param {string} entryPath
23
+ * @param {{ vars?: Record<string, unknown>, cwd?: string }} [options]
24
+ * @returns {CompiledPage}
25
+ */
26
+ export function compileFromMemory(files: Record<string, string> | Map<string, string>, entryPath: string, options?: {
27
+ vars?: Record<string, unknown>;
28
+ cwd?: string;
29
+ }): CompiledPage;
17
30
  /**
18
31
  * Split a raw file into its frontmatter `meta` and `body`.
19
32
  * @param {string} raw Full file contents.
package/types/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export type { RouteManifestEntry, Paths, Assets } from "./builder.js";
8
8
  export {
9
9
  compilePage,
10
10
  compileDocument,
11
+ compileFromMemory,
11
12
  parseFrontmatter,
12
13
  escapeHtml,
13
14
  loopKeyOf