@zvndev/darkmown 2.0.1 → 2.2.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 +18 -0
- package/README.md +61 -6
- package/SECURITY.md +4 -4
- package/docs/cli.md +8 -0
- package/docs/spec-alignment.md +26 -6
- package/grammar/wd-directives.gbnf +58 -0
- package/package.json +5 -1
- package/site/_/footer.wd +1 -1
- package/site/_/nav.wd +1 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/docs/index.wd +19 -2
- package/site/pages/index.wd +5 -3
- package/site/pages/playground/index.js +158 -0
- package/site/pages/playground/index.skin +128 -0
- package/site/pages/playground/index.wd +51 -0
- package/site/pages/showcase/index.wd +1 -1
- package/src/builder.js +15 -8
- package/src/catalog.js +586 -0
- package/src/cli.js +8 -0
- package/src/compiler/actions.js +45 -12
- package/src/compiler/collections.js +16 -11
- package/src/compiler/context.js +52 -3
- package/src/compiler/expr-ast.js +296 -0
- package/src/compiler/fetch.js +12 -4
- package/src/compiler/forms.js +78 -24
- package/src/compiler/fs-reader.js +25 -0
- package/src/compiler/includes.js +20 -15
- package/src/compiler/index.js +1 -1
- package/src/compiler/loops.js +26 -8
- package/src/compiler/media.js +21 -7
- package/src/compiler/page.js +68 -15
- package/src/compiler/predicates.js +29 -18
- package/src/compiler/reader.js +60 -0
- package/src/compiler/state.js +81 -32
- package/src/compiler/structure.js +48 -17
- package/src/compiler.js +57 -6
- package/src/grammar.js +131 -0
- package/src/headers.js +15 -13
- package/src/index.js +2 -0
- package/src/runtime.js +77 -45
- package/src/statics.js +6 -7
- package/types/compiler.d.ts +13 -0
- package/types/index.d.ts +1 -0
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
40
|
-
*
|
|
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
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* `'unsafe-
|
|
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 = [
|
|
111
|
+
export const REACTIVE_CSP = [SCRIPT_SRC, ...COMMON_CSP_DIRECTIVES].join("; ");
|
|
111
112
|
|
|
112
113
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* `
|
|
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
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
// `exports` map; individual modules stay importable by their own path too.
|
|
5
5
|
|
|
6
6
|
export { buildSite } from "./builder.js";
|
|
7
|
+
export { directiveCatalog, llmsText } from "./catalog.js";
|
|
7
8
|
|
|
8
9
|
export {
|
|
9
10
|
compileDocument,
|
|
11
|
+
compileFromMemory,
|
|
10
12
|
compilePage,
|
|
11
13
|
escapeHtml,
|
|
12
14
|
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
|
-
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
250
|
-
const predicateCache = new Map();
|
|
247
|
+
|
|
251
248
|
/**
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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,
|
|
267
|
-
* data-wd-each-class (per loop row); each
|
|
268
|
-
*
|
|
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,
|
|
328
|
+
/** @type {[string, any[]][]} */
|
|
277
329
|
let pairs;
|
|
278
330
|
try { pairs = JSON.parse(raw); } catch { return; }
|
|
279
|
-
for (const [name,
|
|
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
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* / `vercel.json` the same build writes
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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>}
|
package/types/compiler.d.ts
CHANGED
|
@@ -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.
|