@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +31 -7
- package/SECURITY.md +4 -4
- package/docs/spec-alignment.md +19 -6
- package/package.json +5 -2
- 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 +18 -2
- package/site/pages/index.js +19 -0
- package/site/pages/index.skin +46 -1
- package/site/pages/index.wd +7 -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 +10 -8
- package/src/compiler/collections.js +16 -11
- package/src/compiler/context.js +22 -4
- package/src/compiler/expr-ast.js +296 -0
- package/src/compiler/fs-reader.js +25 -0
- package/src/compiler/includes.js +25 -14
- package/src/compiler/index.js +1 -1
- package/src/compiler/loops.js +44 -7
- package/src/compiler/page.js +97 -17
- package/src/compiler/predicates.js +4 -12
- package/src/compiler/reader.js +60 -0
- package/src/compiler/state.js +16 -18
- package/src/compiler/structure.js +40 -10
- package/src/compiler.js +57 -6
- package/src/headers.js +15 -13
- package/src/index.js +1 -0
- package/src/runtime.js +77 -45
- package/src/statics.js +6 -7
- package/src/templates/blog/site/_/nav.wd +6 -9
- package/src/templates/blog/site/pages/index.skin +7 -4
- package/src/templates/blog/site/pages/index.wd +2 -3
- package/src/templates/dashboard/site/_/nav.wd +6 -9
- package/src/templates/dashboard/site/pages/index.skin +7 -4
- package/src/templates/dashboard/site/pages/index.wd +2 -3
- package/src/templates/landing/site/_/nav.wd +6 -9
- package/src/templates/landing/site/pages/index.skin +13 -4
- package/src/templates/landing/site/pages/index.wd +22 -9
- package/src/templates/starter/site/_/nav.wd +7 -10
- package/src/templates/starter/site/pages/about.md +7 -18
- package/src/templates/starter/site/pages/about.skin +0 -25
- package/src/templates/starter/site/pages/index.skin +10 -6
- package/src/templates/starter/site/pages/index.wd +2 -3
- package/src/templates/store/site/_/nav.wd +6 -9
- package/src/templates/store/site/pages/index.skin +7 -4
- package/src/templates/store/site/pages/index.wd +2 -3
- package/types/compiler.d.ts +13 -0
- 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(
|
|
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,
|
|
121
|
+
/** @type {[string, any[]][]} Reactive loop-item class bindings (data-wd-each-class): [class, exprAST]. */
|
|
100
122
|
const eachClasses = [];
|
|
101
|
-
/** @type {[string,
|
|
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;
|
|
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
|
|
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
|
|
3
|
-
// so every existing `import … from "./compiler.js"`
|
|
4
|
-
// working unchanged.
|
|
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
|
-
|
|
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
|
|
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
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>}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
:::
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+
:::
|