@zvndev/darkmown 0.18.0 → 0.19.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 CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
4
4
 
5
+ ## 0.19.0 — 2026-06-25
6
+
7
+ Post-review sprint: deeper reactive loops, hardened deploys, and OS-aware theming.
8
+
9
+ - **Nested reactive loops (one level).** A reactive `@loop` can now contain an inner reactive `@loop <outerItem>.<field> into x` — the inner source is a dotted path on the outer row, so each reconciled row renders its own keyed inner list and both stay live. Interpolation resolves the inner item first, then the outer (`{ line.qty }` and `{ order.ref }` both in scope). Build-time loop nesting over JSON/frontmatter was already supported; what's new is reactive-inside-reactive. Nesting is **one level only** — an inner reactive loop may not contain a third.
10
+ - **Security headers / CSP on deploy.** Builds now emit security response headers so a deployed site is hardened by default: a `dist/_headers` file (Cloudflare Pages format), with the Vercel and local `serve` paths applying the equivalent. Every page carries `X-Content-Type-Options: nosniff`, a `Referrer-Policy`, and a `frame-ancestors` directive. The **Content-Security-Policy is tiered**: static pages get a tight policy, and reactive pages (those shipping `/__wd/runtime.js`) add `script-src 'unsafe-eval' 'unsafe-inline'` for the runtime's `new Function` expression compiler. The default CSP allows same-origin connections only — a `:fetch`/`:form action=` to another host must widen `connect-src` by hand. It is defense-in-depth, not a host allowlist; SSRF protection stays the author's responsibility.
11
+ - **Dark-mode theming — `tokens dark`.** A `.skin` file can add a `tokens dark` block beside its base `tokens`; the dark tokens compile to `@media (prefers-color-scheme: dark) :root { … }`, overriding the matching base tokens under the visitor's OS dark preference. Rules keep referencing the same `$name` tokens, so dark mode is a zero-JavaScript palette swap. A skin with no `tokens dark` block is unchanged.
12
+ - **Runtime test backfill.** Added runtime/compiler tests covering the new reactive-loop nesting and the security-header and theming output.
13
+ - **Compile errors carry a line number.** A compile error now includes the offending line number alongside the file path and corrective suggestion, so the source of a directive mistake is easier to find.
14
+
5
15
  ## 0.18.0 — 2026-06-24
6
16
 
7
17
  Navigation goes instant and flash-free, and images are hardened at compile time.
package/README.md CHANGED
@@ -240,6 +240,34 @@ A `:button` inside a reactive `@loop` can act on its own row. `cart += product`
240
240
 
241
241
  That is a full add-to-cart / remove-line flow — and a to-do list with delete — in plain Markdown, no JavaScript.
242
242
 
243
+ ### Nested reactive loops
244
+
245
+ *New in 0.19.0.* A reactive `@loop` may contain an inner reactive `@loop` over one of the outer row's fields — one level of nesting. The inner source is a dotted path rooted at the outer item (`order.lines`), so each row renders its own list and both stay live as state changes:
246
+
247
+ ```wd
248
+ :state orders = [
249
+ {"id": 1, "ref": "A-100", "lines": [{"sku": "x1", "qty": 2}, {"sku": "x2", "qty": 1}]},
250
+ {"id": 2, "ref": "A-101", "lines": [{"sku": "y9", "qty": 5}]}
251
+ ]
252
+
253
+ @loop orders into order
254
+ ::: card
255
+ **{ order.ref }**
256
+ @loop order.lines into line
257
+ - { line.sku } × { line.qty }
258
+ @endloop
259
+ :::
260
+ @endloop
261
+ ```
262
+
263
+ - The inner loop is keyed and reconciled per outer row, like any reactive loop.
264
+ - Interpolation inside the inner body resolves the inner item first, then the outer item — `{ line.qty }` and `{ order.ref }` are both in scope.
265
+ - Build-time loops over JSON/frontmatter already nest freely (the "loops nest" behavior above); what's new is **reactive-inside-reactive** over a `:state`/`:store`/`:fetch` row field.
266
+
267
+ > **Honest caveat:** nesting is **one level only** — an inner reactive loop may not itself contain a third reactive loop. Build-time loop nesting is unaffected by this limit.
268
+ >
269
+ > **Per-row actions inside an inner loop:** `cart += member` (append-row) works from an inner loop — it carries the inner item into a top-level `:state`/`:store` list. But a per-row **`remove`** needs a top-level list as its source, and an inner loop's source is a path off the outer row (`team.members`), so deleting an inner row in place is **not** supported — the compiler rejects it with a corrective message. Carry the row into a top-level list and remove it there.
270
+
243
271
  ## Sections
244
272
 
245
273
  ```wd
@@ -528,6 +556,26 @@ A trailing `{.class .class #id}` attaches classes / an id to the inline element
528
556
 
529
557
  The block must follow the element with no space, and works on links, images, emphasis, and inline code. It never collides with `{ name }` interpolation — an interpolation always starts with a name, never a `.` or `#`.
530
558
 
559
+ ## Dark mode — `tokens dark`
560
+
561
+ *New in 0.19.0.* A colocated `.skin` file already declares its palette in a `tokens` block (`name value` pairs referenced elsewhere as `$name`). Add a second `tokens dark` block to override any of those tokens under the visitor's OS dark preference — it compiles to a `@media (prefers-color-scheme: dark) :root { … }` rule, so the page follows the system theme with **zero JavaScript**.
562
+
563
+ ```skin
564
+ tokens
565
+ paper #ffffff
566
+ ink #171717
567
+
568
+ tokens dark
569
+ paper #0b0b0f
570
+ ink #f4f4f5
571
+
572
+ page
573
+ bg $paper
574
+ color $ink
575
+ ```
576
+
577
+ The `tokens dark` values override the matching base tokens only when the OS reports a dark preference; the rules below keep referencing the same `$paper` / `$ink`. The base `tokens` block stays the light default, so a skin with no `tokens dark` block is unchanged.
578
+
531
579
  ## The escape hatch
532
580
 
533
581
  Reactive pages expose `window.wd` — `wd.get(key)`, `wd.set(key, value)`, `wd.state`, `wd.render()` — so colocated `.js` can do anything the directives can't. Section-scoped keys are addressed as `sectionId:name`.
@@ -548,10 +596,19 @@ Darkmown is a **trusted-author** site generator: you compile content you wrote,
548
596
  - **Raw HTML passes through by default.** Darkmown runs `markdown-it` with `html: true`, so raw HTML in your content is emitted verbatim — by design, like every Markdown site generator. There is no built-in sanitizer; a raw `<script>` or event-handler attribute in untrusted content would execute in the visitor's browser. A page can opt into strict mode with `html: false` frontmatter, which drops raw HTML entirely.
549
597
  - **`:fetch` and `:form action=` have no host allowlist, and reactive pages need `unsafe-eval`.** A fetch/form URL is taken straight from the page source; the compiler rejects non-http(s) schemes but does not restrict which hosts you call, so SSRF/exfiltration protection is the author's responsibility. Reactive pages also require `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions via `new Function` — a strict CSP without `unsafe-eval` will break reactive pages (static pages are unaffected).
550
598
 
551
- > **The single biggest footgun:** do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer.
599
+ > **The single biggest footgun:** do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer. A page can flip to strict mode with `html: false` frontmatter, which drops raw HTML entirely (see [SECURITY.md](SECURITY.md)).
552
600
 
553
601
  Directive actions and `:computed`/`@loop … where` expressions are never `eval`'d as raw user content — they compile to a whitelisted grammar (item paths, declared `:state`, numbers, strings) that runs via `new Function`. See [SECURITY.md](SECURITY.md) for the full security model.
554
602
 
603
+ ### Shipped security headers
604
+
605
+ Builds emit security response headers so a deployed site gets sane defaults without hand-writing a config. The build writes a `dist/_headers` file (Cloudflare Pages format), and the Vercel/local `serve` paths apply the equivalent. On every page:
606
+
607
+ - **`Content-Security-Policy`** — static pages get a tight policy; **reactive pages** (those that ship `/__wd/runtime.js`) add `script-src 'unsafe-eval' 'unsafe-inline'`, which the runtime needs to compile validated `:computed` / `@loop … where` / `.class when` expressions via `new Function`. Static pages stay strict.
608
+ - **`X-Content-Type-Options: nosniff`**, **`Referrer-Policy`**, and a **`frame-ancestors`** directive (clickjacking protection) on every page.
609
+
610
+ **If you use `:fetch` or `:form action=` against another host, widen `connect-src`.** The shipped CSP allows same-origin connections; calling a third-party API is otherwise blocked by the policy. Add the host to `connect-src` in your deploy config (it is not auto-derived from your page sources). The CSP is a defense-in-depth layer — it does not replace the trust-boundary rules above; see [SECURITY.md](SECURITY.md) for tightening guidance.
611
+
555
612
  ## Spec status
556
613
 
557
614
  See [docs/spec-alignment.md](docs/spec-alignment.md) for the deep alignment audit against the original vision.
package/SECURITY.md CHANGED
@@ -31,10 +31,45 @@ Things Darkmown deliberately guards at compile time and runtime:
31
31
  - **Output escaping.** Interpolated values are HTML-escaped; state scripts escape `<` to prevent script-tag breakout.
32
32
  - **Static server path containment.** The dev/preview servers resolve requests strictly inside `dist`.
33
33
 
34
- ## The biggest footgun: raw HTML passthrough
34
+ ## The #1 footgun: raw HTML passthrough (`html: true`)
35
35
 
36
- Darkmown configures markdown-it with `html: true`, so **raw HTML in `.md`/`.wd` files is passed through verbatim** — by design, like every Markdown site generator. This is the single most important thing to understand about Darkmown's security model.
36
+ This is the single most important thing to understand about Darkmown's security model. **Read this before pointing Darkmown at anything you did not write yourself.**
37
37
 
38
- - Treat all content files as **trusted input**, the same way you trust your own source code.
39
- - **Do not compile untrusted or user-submitted Markdown** (comments, form input, third-party docs) without sanitizing it first. Darkmown ships no built-in sanitizer; raw `<script>` and event-handler attributes in untrusted content would execute in the visitor's browser.
38
+ Darkmown configures markdown-it with `html: true`, so **raw HTML in `.md`/`.wd` files is passed through verbatim** — by design, like every Markdown site generator. A raw `<script>` or an `onerror=` attribute in your content reaches the visitor's browser and runs.
39
+
40
+ - **Treat every content file as trusted input**, the same way you trust your own source code.
41
+ - **Never compile untrusted or user-submitted Markdown** (comments, form input, third-party docs, scraped content) without sanitizing it first. Darkmown ships **no built-in sanitizer**.
40
42
  - If you must render untrusted content, sanitize it (for example with a library like DOMPurify) before it reaches the compiler.
43
+
44
+ ### The `html: false` per-page opt-out
45
+
46
+ The built-in mitigation is per-page strict mode. Add `html: false` to a page's frontmatter and that page's Markdown renders through a strict renderer that **escapes raw HTML entirely** — a stray `<script>` becomes inert text instead of executing.
47
+
48
+ ```wd
49
+ ---
50
+ title: User comments
51
+ html: false
52
+ ---
53
+ ```
54
+
55
+ Use it for any page whose content is even partly out of your hands. The default stays `html: true` because the framework targets trusted authors; `html: false` is the switch for the pages where that assumption doesn't hold.
56
+
57
+ > **Note:** `html: false` is a *per-page* frontmatter key — there is no global/site-wide `html: false` default today (no config loader exists yet). Flipping the global default to strict-by-default is a deliberate future option, not a shipped setting; until then, set `html: false` on each page that needs it.
58
+
59
+ ## Deploying with a Content-Security-Policy
60
+
61
+ Builds emit security response headers so a deployed site is hardened by default rather than relying on hand-written config. The build writes a `dist/_headers` file (Cloudflare Pages format) and the Vercel and local `serve` paths apply the equivalent. Every page gets:
62
+
63
+ - **`Content-Security-Policy`** — **static pages** (no runtime) get a tight policy. **Reactive pages** (those that ship `/__wd/runtime.js`) additionally need `script-src 'unsafe-eval' 'unsafe-inline'`, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions via `new Function`. A strict CSP without `unsafe-eval` breaks reactive pages; static pages are unaffected and stay strict.
64
+ - **`X-Content-Type-Options: nosniff`** — stops MIME-type sniffing.
65
+ - **`Referrer-Policy`** — limits referrer leakage.
66
+ - **`frame-ancestors`** — clickjacking protection (controls who may frame the page).
67
+
68
+ ### Tightening the policy
69
+
70
+ The shipped CSP is a sensible default, not a finished policy for every site. Tighten or widen it for your deployment:
71
+
72
+ - **`:fetch` / `:form action=` to another host needs a wider `connect-src`.** The default CSP permits same-origin connections; a call to a third-party API is otherwise blocked. Add each external host to `connect-src` explicitly — it is **not** auto-derived from your page sources.
73
+ - **Remote images, fonts, or embeds** need their hosts added to `img-src` / `font-src` / `frame-src`.
74
+ - **Reactive pages** require `unsafe-eval` in `script-src`; do not remove it on a page that ships the runtime. If you can keep a page static, it can run under a stricter policy.
75
+ - The CSP is **defense-in-depth** — it limits the blast radius of a mistake but does **not** replace the trust-boundary rules above. It is not a substitute for sanitizing untrusted content, and it does not add a host allowlist to `:fetch`/`:form` (SSRF/exfiltration protection remains the author's responsibility).
@@ -84,6 +84,15 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
84
84
  - **Instant, flash-free navigation under `transitions: true`:** alongside the cross-document view transition, the page emits a declarative `<script type="speculationrules">` that **prerenders** same-origin links at `moderate` eagerness (hover/pointerdown). A prerendered page activates instantly, so there is no render-gap white flash for the transition to cover; the transition itself is now a directional fade+slide (old lifts out, new rises in, 200 ms eased) instead of the UA cross-fade that superimposed both pages at ~50 % opacity. Browser-interpreted JSON, not framework JS — static pages stay zero-JS; `prefers-reduced-motion` is honored; `{.no-prefetch}` / `rel=nofollow` opt links out. (Chrome disables prerendering while DevTools is open.)
85
85
  - **Compile-time image hardening:** every emitted `<img>` is stamped with intrinsic `width`/`height` (read from disk via the `image-size` build-time dependency — no runtime cost), `decoding="async"`, and `fetchpriority="high"` on the first image / `loading="lazy"` on the rest. Eliminates cumulative layout shift and de-prioritizes below-the-fold images. Author attributes are never overwritten; remote/unreadable sources degrade to no dimensions.
86
86
 
87
+ ## Stage 15 additions (2026-06-25, v0.19.0)
88
+
89
+ The post-8/10-review sprint: one reactive-loop capability, a deploy-hardening layer, and a theming primitive — plus a test backfill and clearer compile errors. Tracks landed in parallel; behavior below reflects the intended surface, and any item gated on the runtime-size budget is flagged.
90
+
91
+ - **Nested reactive loops (one level):** a reactive `@loop` may contain an inner reactive `@loop <outerItem>.<field> into x` — the inner source is a dotted path rooted at the outer row, so each reconciled outer row renders its own keyed inner list and both stay live. Interpolation inside the inner body resolves the inner item first, then the outer (`{ line.qty }` and `{ order.ref }` both in scope). Build-time loop nesting over JSON/frontmatter was already supported; what's new is reactive-inside-reactive over a `:state`/`:store`/`:fetch` row field. **Honest caveat:** nesting is **one level only** — an inner reactive loop may not contain a third reactive loop. It shipped within the runtime-size budget (5910 B gzipped, no minification). A second caveat: per-row `cart += member` (append-row) works from an inner loop, but a per-row **`remove`** needs a top-level `:state`/`:store` list as its source — an inner loop's source is a path off the outer row, so deleting an inner row in place is unsupported and the compiler rejects it with a corrective hint (carry the row into a top-level list, remove it there).
92
+ - **Security headers / CSP on deploy:** the build emits security response headers so a deployed site is hardened by default — `dist/_headers` (Cloudflare Pages format), with the Vercel and local `serve` paths applying the equivalent. Every page carries `X-Content-Type-Options: nosniff`, a `Referrer-Policy`, and a `frame-ancestors` directive. **Content-Security-Policy is tiered like the runtime:** static pages get a tight policy; reactive pages (those shipping `/__wd/runtime.js`) add `script-src 'unsafe-eval' 'unsafe-inline'`, which the runtime needs to run validated `:computed` / `@loop … where` / `.class when` expressions via `new Function`. **Honest caveat:** the shipped CSP allows only same-origin connections — a `:fetch`/`:form action=` to another host must widen `connect-src` by hand (it is not derived from page sources), and the CSP remains defense-in-depth, **not** a host allowlist for `:fetch` (SSRF stays the author's responsibility per the trust boundary). The `html: false` per-page strict-render opt-out is unchanged; a global strict-by-default `html` setting remains a deliberate future option (no config loader exists yet).
93
+ - **Dark-mode theming — `tokens dark`:** a `.skin` file may add a `tokens dark` block alongside its base `tokens`; the dark tokens compile to `@media (prefers-color-scheme: dark) :root { … }`, overriding the matching base tokens under the visitor's OS dark preference. Rules keep referencing the same `$name` tokens, so dark mode is a palette swap with **zero JavaScript** — consistent with the existing zero-JS `.skin` output. A skin with no `tokens dark` block is unchanged.
94
+ - **Test backfill + compile-error line numbers:** the sprint backfilled runtime/compiler tests for the new surface, and compile errors now carry the offending **line number** alongside the file path and corrective suggestion. **Honest caveat:** this entry documents the intended sprint scope; the exact assertion count and the precise error-message format are owned by the test/compiler tracks and were not verified from this docs track.
95
+
87
96
  ## Stage 13 additions (2026-06-23, v0.17.0)
88
97
 
89
98
  - **`:checkbox` and `:radio` field groups:** `:checkbox name` / `:radio name` (with `- Label` option lines) render a labelled `role="group"` / `role="radiogroup"` of `<input>`s that share one `name`, each wrapped in its own `<label>` and deriving an aria-label like the other fields. A `:checkbox` group is marked `data-wd-multi` so the runtime's submit handler collects **every** checked value into an array (`FormData.getAll`) instead of collapsing to the last — the one runtime change (compile-time only otherwise; shipped runtime stays ~5.8 KB). A `:radio` group captures a single value like `:input`. Closes the multi-select / single-choice gap that previously forced raw HTML and lost `:form into` capture.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -48,8 +48,8 @@
48
48
  "devDependencies": {
49
49
  "@playwright/test": "^1.49.0",
50
50
  "@types/markdown-it": "^14.1.2",
51
- "@types/node": "^22.19.21",
52
- "typescript": "^5.9.3"
51
+ "@types/node": "^26.0.0",
52
+ "typescript": "^6.0.3"
53
53
  },
54
54
  "repository": {
55
55
  "type": "git",
package/site/_/nav.wd CHANGED
@@ -6,6 +6,7 @@
6
6
  <a href="/reactive/">Reactive</a>
7
7
  <a href="/loops/">Loops</a>
8
8
  <a href="/store/">Store</a>
9
+ <a href="/theme/">Theme</a>
9
10
  <a href="/data/">Data &amp; Forms</a>
10
11
  <a href="/app/">Live app</a>
11
12
  </nav>
@@ -84,4 +84,26 @@ No sort key needed — `reverse` walks the list back to front:
84
84
  - { r.label }
85
85
  @endloop
86
86
 
87
+ ## Nested reactive loops
88
+
89
+ *New in 0.19.0.* A reactive `@loop` can hold an inner reactive `@loop` over a
90
+ field of the outer row — one level of nesting. Type to filter the teams: the
91
+ outer list re-filters live and every team keeps its own member list.
92
+
93
+ :state teams = [{"id": 1, "name": "Platform", "members": [{"id": 11, "name": "Ada"}, {"id": 12, "name": "Bao"}]}, {"id": 2, "name": "Growth", "members": [{"id": 21, "name": "Cyrus"}, {"id": 22, "name": "Dax"}]}, {"id": 3, "name": "Research", "members": [{"id": 31, "name": "Esme"}]}]
94
+ :state teamQuery = ""
95
+
96
+ :bind teamQuery placeholder="Filter teams…"
97
+
98
+ @loop teams into team where team.name contains teamQuery
99
+ ::: card .person-row
100
+ **{ team.name }**
101
+ @loop team.members into member
102
+ - { member.name }
103
+ @endloop
104
+ :::
105
+ @empty
106
+ No teams match.
107
+ @endloop
108
+
87
109
  </main>
@@ -0,0 +1,53 @@
1
+ tokens
2
+ ink #1a1a1a
3
+ paper #ffffff
4
+ panel #ffffff
5
+ line #dedbd3
6
+ accent #16645a
7
+ radius 8px
8
+
9
+ tokens dark
10
+ ink #f0f0f0
11
+ paper #14140f
12
+ panel #1e1d18
13
+ line #34322b
14
+ accent #5fd0c0
15
+
16
+ page
17
+ margin 0
18
+ font ui-serif, Georgia, Cambria, serif
19
+ color $ink
20
+ bg $paper
21
+ line-height 1.7
22
+
23
+ main
24
+ max-width 820px
25
+ margin 0 auto
26
+ padding 3rem 2rem 5rem
27
+
28
+ h1
29
+ font-size 3rem
30
+ line-height 1
31
+
32
+ h2
33
+ margin-top 2.4rem
34
+ border-top 1px solid $line
35
+ padding-top 1.2rem
36
+
37
+ a
38
+ color $accent
39
+
40
+ pre
41
+ bg $panel
42
+ border 1px solid $line
43
+ padding 1rem
44
+ radius $radius
45
+ overflow-x auto
46
+
47
+ .swatch
48
+ border 1px solid $line
49
+ bg $panel
50
+ color $ink
51
+ padding 1.4rem
52
+ margin 1.5rem 0
53
+ radius $radius
@@ -0,0 +1,58 @@
1
+ ---
2
+ title: Dark-mode tokens
3
+ transitions: true
4
+ ---
5
+
6
+ @include /nav.wd
7
+
8
+ <main>
9
+
10
+ # Dark-mode tokens
11
+
12
+ Darkmown skins get automatic, OS-driven theming with **zero JavaScript**. Declare
13
+ your design tokens once under `tokens`, then declare the dark overrides under
14
+ `tokens dark`. The light values land on `:root`; the dark values compile to a
15
+ `prefers-color-scheme: dark` media query over the same custom properties.
16
+
17
+ ```
18
+ tokens
19
+ ink #1a1a1a
20
+ paper #ffffff
21
+ tokens dark
22
+ ink #f0f0f0
23
+ paper #14140f
24
+ ```
25
+
26
+ Every `$ink` / `$paper` reference keeps working unchanged — the variables simply
27
+ resolve to their dark values when the operating system asks for dark mode.
28
+
29
+ ## See it live
30
+
31
+ This page's `theme.skin` uses exactly that pattern. Switch your OS (or browser)
32
+ appearance between light and dark and watch the panel below recolor itself — no
33
+ toggle, no runtime, no flash.
34
+
35
+ ::: card .swatch
36
+ The card surface, border, ink, and accent below are all driven by `tokens` plus
37
+ a `tokens dark` override. Nothing on this page ships framework JavaScript.
38
+ :::
39
+
40
+ [A link tinted with the accent token](/docs/) sits in the same scope, so it
41
+ recolors with the theme too.
42
+
43
+ ## Manual toggle (optional variant)
44
+
45
+ For an author who wants an explicit switch instead of (or alongside) the OS
46
+ preference, a bracketed modifier scopes the same tokens to a `:root` attribute:
47
+
48
+ ```
49
+ tokens [data-theme=dark]
50
+ ink #f0f0f0
51
+ paper #14140f
52
+ ```
53
+
54
+ That compiles to `:root[data-theme="dark"] { … }`, so setting `data-theme="dark"`
55
+ on the document root flips the palette — pair it with your own toggle when you
56
+ need manual control.
57
+
58
+ </main>
package/src/builder.js CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { compilePage } from "./compiler.js";
5
5
  import { createPaths } from "./config.js";
6
+ import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
6
7
  import { discoverRoutes, outputPathForRoute } from "./router.js";
7
8
  import { compileSkin } from "./skin.js";
8
9
 
@@ -72,6 +73,12 @@ export function buildSite(cwd = process.cwd()) {
72
73
 
73
74
  fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
74
75
 
76
+ // Cloudflare Pages reads dist/_headers for security headers (Vercel reads
77
+ // vercel.json; the local server adds them in src/statics.js). Static routes
78
+ // get the stricter CSP (no 'unsafe-eval'); reactive routes get the relaxed
79
+ // one. A catch-all keeps assets and any future path covered.
80
+ fs.writeFileSync(path.join(paths.distRoot, "_headers"), renderCloudflareHeaders(manifest));
81
+
75
82
  // Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
76
83
  // sees every emitted route/framework file already on disk.
77
84
  emitPageAssets(paths);
@@ -103,6 +110,49 @@ export function stripRuntimeComments(source) {
103
110
  .replace(/^[ \t]*\n/gm, "");
104
111
  }
105
112
 
113
+ /**
114
+ * Render the Cloudflare Pages `_headers` file from the route manifest.
115
+ *
116
+ * Cloudflare applies every matching block in order; when blocks set the same
117
+ * header, the later block wins. So we emit the catch-all `/*` first (baseline
118
+ * security headers + the relaxed reactive CSP that satisfies every page), then
119
+ * one block per static route that overrides `Content-Security-Policy` with the
120
+ * stricter, eval-free CSP. Reactive routes need no override — the catch-all
121
+ * already carries the relaxed CSP.
122
+ *
123
+ * Clean URLs mean a route like `/docs/` is served at both `/docs` and `/docs/`,
124
+ * so each static route emits a path glob (`/docs`, `/docs/*`) that covers both.
125
+ * @param {RouteManifestEntry[]} manifest
126
+ * @returns {string}
127
+ */
128
+ export function renderCloudflareHeaders(manifest) {
129
+ const baseLines = Object.entries(BASE_SECURITY_HEADERS).map(([name, value]) => ` ${name}: ${value}`);
130
+ const blocks = [
131
+ ["/*", [...baseLines, ` Content-Security-Policy: ${REACTIVE_CSP}`]]
132
+ ];
133
+
134
+ for (const entry of manifest) {
135
+ if (entry.assets.runtime) continue; // reactive routes keep the catch-all (relaxed) CSP
136
+ for (const pattern of cloudflarePathPatterns(entry.route)) {
137
+ blocks.push([pattern, [` Content-Security-Policy: ${STATIC_CSP}`]]);
138
+ }
139
+ }
140
+
141
+ return `${blocks.map(([pattern, lines]) => [pattern, ...lines].join("\n")).join("\n\n")}\n`;
142
+ }
143
+
144
+ /**
145
+ * Cloudflare `_headers` path patterns covering a clean-URL route. `/` →
146
+ * `["/", "/index.html"]`; `/docs/` → `["/docs", "/docs/*"]`.
147
+ * @param {string} route Route path from the manifest (always trailing-slashed).
148
+ * @returns {string[]}
149
+ */
150
+ function cloudflarePathPatterns(route) {
151
+ if (route === "/") return ["/", "/index.html"];
152
+ const trimmed = route.replace(/\/$/, ""); // "/docs/" -> "/docs"
153
+ return [trimmed, `${trimmed}/*`];
154
+ }
155
+
106
156
  /**
107
157
  * A minimal, zero-JS fallback 404 page used only when no `404.wd` route exists.
108
158
  * @returns {string}
package/src/compiler.js CHANGED
@@ -327,6 +327,7 @@ function compileFile(file, context, stack, scope, comp, sections, loopItem) {
327
327
 
328
328
  const raw = fs.readFileSync(file, "utf8");
329
329
  const { meta, body } = parseFrontmatter(raw, file);
330
+ warnLikelyFrontmatter(raw, file, comp);
330
331
  collectColocatedAssets(file, context, comp.assets);
331
332
 
332
333
  if (path.extname(file) === ".md") {
@@ -365,6 +366,37 @@ export function parseFrontmatter(raw, file) {
365
366
  return { meta, body };
366
367
  }
367
368
 
369
+ /**
370
+ * Warn (non-fatal) when a file looks like it MEANT to open frontmatter but forgot
371
+ * the leading `---`: the first content line is a `key: value` pair and a bare
372
+ * `---` fence appears within the opening lines. Conservative — a normal markdown
373
+ * body whose first line happens to contain a colon (with no early `---`) is left
374
+ * alone, since `---` is also a valid horizontal rule.
375
+ * @param {string} raw
376
+ * @param {string} file
377
+ * @param {Compilation} comp
378
+ * @returns {void}
379
+ */
380
+ function warnLikelyFrontmatter(raw, file, comp) {
381
+ if (raw.startsWith("---\n")) return; // a real opener: parseFrontmatter handled it
382
+ const lines = raw.replace(/\r\n?/g, "\n").split("\n");
383
+ let i = 0;
384
+ while (i < lines.length && !lines[i].trim()) i++; // skip leading blanks
385
+ if (i >= lines.length || !/^[A-Za-z][\w-]*:\s*\S/.test(lines[i])) return;
386
+ // Only warn when the run of lines up to a bare `---` is ALL `key: value` pairs:
387
+ // a forgotten opener, not prose that merely contains a colon and a later rule.
388
+ for (let j = i; j < lines.length && j < i + 12; j++) {
389
+ const t = lines[j].trim();
390
+ if (t === "---") {
391
+ comp.warnings.push(
392
+ `${file}: this looks like frontmatter missing its opening "---". Use: a "---" line before the fields (and another "---" to close) — otherwise the block renders as page text.`
393
+ );
394
+ return;
395
+ }
396
+ if (t && !/^[A-Za-z][\w-]*:\s*\S/.test(t)) return; // hit prose → not frontmatter
397
+ }
398
+ }
399
+
368
400
  // Frontmatter values are scalars, except an inline flow array `[a, b, c]`.
369
401
  // Block sequences (`- item` on following lines) are intentionally out of scope —
370
402
  // the parser stays single-pass and line-based.
@@ -571,6 +603,7 @@ function compileBody(lines, ctx) {
571
603
  if (/^(@endloop|:endif|:endfor|:endform|:else)\s*$/.test(line)) {
572
604
  throw new Error(`Stray "${line.trim()}" with no matching opener in ${ctx.file}`);
573
605
  }
606
+ warnUnknownDirective(line, ctx);
574
607
  prose.push(line);
575
608
  }
576
609
 
@@ -578,6 +611,44 @@ function compileBody(lines, ctx) {
578
611
  return out.join("\n");
579
612
  }
580
613
 
614
+ // Lines that reached prose but LOOK like a directive (a `@word`/`:word` token at
615
+ // the very start, followed by a space or end of line) and match no handler are
616
+ // almost always a typo — every real directive was intercepted above. Warn, never
617
+ // throw: prose legitimately contains `@` and `:`, so the match is deliberately
618
+ // narrow (lowercase token + boundary; emoji shortcodes, times, and `@user`
619
+ // followed by punctuation do not match).
620
+ const KNOWN_DIRECTIVE =
621
+ /^(?:@(?:include|loop|empty|endloop)|:(?:state|store|fetch|computed|effect|form|endform|input|textarea|select|checkbox|radio|bind|submit|button|if|endif|else|try|note|sprint))(?:\s|$)/;
622
+ /**
623
+ * @param {string} line
624
+ * @param {Ctx} ctx
625
+ * @returns {void}
626
+ */
627
+ function warnUnknownDirective(line, ctx) {
628
+ // Catch capitalized (`:State`) and hyphenated (`:end-loop`) typos too — the
629
+ // known-directive whitelist stays case-sensitive, so a mis-cased real
630
+ // directive correctly trips the warning. Still unindented-only, to keep prose
631
+ // that legitimately starts lines with `@`/`:` from false-positiving.
632
+ const m = line.match(/^([@:][A-Za-z][A-Za-z-]*)(?:\s|$)/);
633
+ if (m && !KNOWN_DIRECTIVE.test(line)) {
634
+ ctx.comp.warnings.push(
635
+ `${ctx.file}: "${m[1]}" looks like a directive but matches none — it will render as literal text. Check the spelling (e.g. @loop, @include, :state, :if).`
636
+ );
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Format a source location as `file:line` (1-based) for compile errors. Keeps the
642
+ * file path intact so existing message matchers still pass, while pointing at the
643
+ * unclosed opener's line.
644
+ * @param {string} file
645
+ * @param {number} index 0-based line index of the opener.
646
+ * @returns {string}
647
+ */
648
+ function at(file, index) {
649
+ return `${file}:${index + 1}`;
650
+ }
651
+
581
652
  /**
582
653
  * Capture lines from an opener until a matching close token, honoring nesting and fences.
583
654
  * @param {string[]} lines
@@ -612,7 +683,7 @@ function scanBlock(lines, start, openRe, endToken, file) {
612
683
  }
613
684
  body.push(line);
614
685
  }
615
- throw new Error(`Missing ${endToken} for "${lines[start]}" in ${file}`);
686
+ throw new Error(`Missing ${endToken} for "${lines[start]}" in ${at(file, start)}`);
616
687
  }
617
688
 
618
689
  /**
@@ -683,7 +754,7 @@ function scanContainer(lines, start, file) {
683
754
  }
684
755
  body.push(line);
685
756
  }
686
- throw new Error(`Missing closing ::: for "${lines[start]}" in ${file}`);
757
+ throw new Error(`Missing closing ::: for "${lines[start]}" in ${at(file, start)}`);
687
758
  }
688
759
 
689
760
  /**
@@ -752,7 +823,7 @@ function scanConditional(lines, start, file) {
752
823
  }
753
824
  current.push(line);
754
825
  }
755
- throw new Error(`Missing :endif for "${lines[start]}" in ${file}`);
826
+ throw new Error(`Missing :endif for "${lines[start]}" in ${at(file, start)}`);
756
827
  }
757
828
 
758
829
  // ---------------------------------------------------------------------------
@@ -1637,6 +1708,11 @@ function handleLoop(line, bodyLines, emptyLines, ctx) {
1637
1708
  const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
1638
1709
  return reactiveLoop(fullKey, itemName, bodyLines, ctx, opts);
1639
1710
  }
1711
+ // Inside a reactive loop, `@loop <outerItem>.<path> into x` loops a field of
1712
+ // the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
1713
+ if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
1714
+ return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, ctx, opts);
1715
+ }
1640
1716
  }
1641
1717
 
1642
1718
  throw new Error(
@@ -1911,15 +1987,7 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
1911
1987
  const templateCtx = { ...ctx, loopItem: itemName, loopKey: key ?? undefined, loopMeta: true, scope: createScope(ctx.scope) };
1912
1988
  const templateHtml = compileBody(bodyLines, templateCtx).trim();
1913
1989
 
1914
- let wrapperTag = "div";
1915
- let itemTemplate = templateHtml;
1916
- const listMatch = templateHtml.match(/^<(ul|ol)>\s*([\s\S]*?)\s*<\/\1>$/);
1917
- if (listMatch && (listMatch[2].match(/<li>/g) || []).length === 1) {
1918
- wrapperTag = listMatch[1];
1919
- itemTemplate = listMatch[2].trim();
1920
- } else {
1921
- itemTemplate = `<div data-wd-loop-piece>${templateHtml}</div>`;
1922
- }
1990
+ const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
1923
1991
 
1924
1992
  const where = opts?.where || null;
1925
1993
  const sort = opts?.sort || null;
@@ -1941,27 +2009,132 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
1941
2009
  /** @type {Map<string, number>} */
1942
2010
  const counts = new Map();
1943
2011
  const count = rows.length;
2012
+ // Inner ITEM-RELATIVE loop regions are filled per row by the runtime, not at
2013
+ // build time; mask them so the outer initial-paint string fill leaves their
2014
+ // pristine <template>/empty-out markup untouched, then restore.
2015
+ const { masked, regions } = maskItemLoops(itemTemplate);
1944
2016
  const initial = rows
1945
2017
  .map((/** @type {unknown} */ item, i) => {
1946
2018
  const itemKey = loopKeyOf(item, counts);
1947
2019
  const meta = { index: i, number: i + 1, first: i === 0, last: i === count - 1, count };
1948
- return fillTemplateString(withLoopKey(itemTemplate, itemKey), item, meta);
2020
+ return unmaskItemLoops(fillTemplateString(withLoopKey(masked, itemKey), item, meta), regions);
1949
2021
  })
1950
2022
  .join("");
1951
2023
 
1952
- const emptyTemplate = emptyLines ? `<template data-wd-loop-empty>${compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()}</template>` : "";
2024
+ const emptyTemplate = loopEmptyTemplate(emptyLines, ctx);
1953
2025
  const initialOut = count === 0 && emptyLines ? compileBody(emptyLines, { ...ctx, loopMeta: true }).trim() : initial;
1954
2026
 
1955
- const attrs =
1956
- (where ? ` data-wd-loop-where="${escapeHtml(where.body)}"` : "") +
1957
- (sort ? ` data-wd-loop-sort="${escapeHtml(sort.key)}" data-wd-loop-sort-dir="${sort.dir}"` : "") +
1958
- (reverse ? ` data-wd-loop-reverse` : "") +
1959
- (offset ? ` data-wd-loop-offset="${numArgAttr(offset)}"` : "") +
1960
- (limit ? ` data-wd-loop-limit="${numArgAttr(limit)}"` : "") +
2027
+ const attrs = loopClauseAttrs({ where, sort, reverse, offset, limit }) +
1961
2028
  (baked ? ` data-wd-loop-data="${escapeHtml(JSON.stringify(baked))}"` : "");
1962
2029
  return `<div data-wd-loop="${escapeHtml(key || "")}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out>${initialOut}</${wrapperTag}></div>`;
1963
2030
  }
1964
2031
 
2032
+ /**
2033
+ * Unwrap a compiled row body into a wrapper tag + row template. A body that is a
2034
+ * single-`<li>` `<ul>`/`<ol>` becomes an `<li>` row under that list wrapper;
2035
+ * anything else is wrapped in a `<div data-wd-loop-piece>` under a `<div>`. Shared
2036
+ * by top-level reactive loops and item-relative (nested) loops.
2037
+ * @param {string} templateHtml
2038
+ * @returns {{ wrapperTag: string, itemTemplate: string }}
2039
+ */
2040
+ function wrapRowTemplate(templateHtml) {
2041
+ const listMatch = templateHtml.match(/^<(ul|ol)>\s*([\s\S]*?)\s*<\/\1>$/);
2042
+ if (listMatch && (listMatch[2].match(/<li>/g) || []).length === 1) {
2043
+ return { wrapperTag: listMatch[1], itemTemplate: listMatch[2].trim() };
2044
+ }
2045
+ return { wrapperTag: "div", itemTemplate: `<div data-wd-loop-piece>${templateHtml}</div>` };
2046
+ }
2047
+
2048
+ /**
2049
+ * Compile an `@empty` branch body into a `<template data-wd-loop-empty>` (or "" when
2050
+ * there is no empty branch). Shared by top-level and item-relative loops.
2051
+ * @param {string[] | null} emptyLines
2052
+ * @param {Ctx} ctx
2053
+ * @returns {string}
2054
+ */
2055
+ function loopEmptyTemplate(emptyLines, ctx) {
2056
+ return emptyLines ? `<template data-wd-loop-empty>${compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()}</template>` : "";
2057
+ }
2058
+
2059
+ /**
2060
+ * Serialize the shared `where`/`sort`/`reverse`/`offset`/`limit` clause config to
2061
+ * the `data-wd-loop-*` attribute string consumed by the runtime pipeline. Shared
2062
+ * by top-level reactive loops and item-relative (nested) loops.
2063
+ * @param {Pick<LoopOpts, "where" | "sort" | "reverse" | "offset" | "limit">} c
2064
+ * @returns {string}
2065
+ */
2066
+ function loopClauseAttrs(c) {
2067
+ return (
2068
+ (c.where ? ` data-wd-loop-where="${escapeHtml(c.where.body)}"` : "") +
2069
+ (c.sort ? ` data-wd-loop-sort="${escapeHtml(c.sort.key)}" data-wd-loop-sort-dir="${c.sort.dir}"` : "") +
2070
+ (c.reverse ? ` data-wd-loop-reverse` : "") +
2071
+ (c.offset ? ` data-wd-loop-offset="${numArgAttr(c.offset)}"` : "") +
2072
+ (c.limit ? ` data-wd-loop-limit="${numArgAttr(c.limit)}"` : "")
2073
+ );
2074
+ }
2075
+
2076
+ /**
2077
+ * Emit ONE level of nested reactive loop: a `@loop <outerItem>.<path> into x`
2078
+ * whose rows are read off the enclosing loop row at runtime. The region carries
2079
+ * its own row <template> + clause config but, unlike a top-level loop, its source
2080
+ * is the relative `path` (not a global state key) — `fillItem` resolves the rows
2081
+ * off the current outer item and reconciles per row. The output starts empty; the
2082
+ * runtime fills it on first render (the page already ships the runtime).
2083
+ * @param {string} path Dotted sub-path off the outer item (e.g. `items`).
2084
+ * @param {string} itemName Inner loop item name.
2085
+ * @param {string[]} bodyLines Inner loop body.
2086
+ * @param {Ctx} ctx
2087
+ * @param {Partial<LoopOpts>} opts
2088
+ * @returns {string}
2089
+ */
2090
+ function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
2091
+ /** @type {Ctx} */
2092
+ // loopKey: undefined — an item-relative loop's source is a path off the outer
2093
+ // row, not a top-level state key, so a per-row `remove` inside it has no valid
2094
+ // target and must be rejected (rather than inheriting the outer loop's key).
2095
+ const templateCtx = { ...ctx, loopItem: itemName, loopKey: undefined, loopMeta: true, scope: createScope(ctx.scope) };
2096
+ const templateHtml = compileBody(bodyLines, templateCtx).trim();
2097
+
2098
+ const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
2099
+
2100
+ const emptyTemplate = loopEmptyTemplate(opts.empty || null, ctx);
2101
+ const attrs = loopClauseAttrs({ where: opts.where || null, sort: opts.sort || null, reverse: opts.reverse || false, offset: opts.offset || null, limit: opts.limit || null });
2102
+ return `<div data-wd-loop-item="${escapeHtml(path)}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out></${wrapperTag}></div>`;
2103
+ }
2104
+
2105
+ /**
2106
+ * Replace each top-level `data-wd-loop-item` region in a row template with an
2107
+ * opaque placeholder so the outer initial-paint string fill cannot reach inside
2108
+ * its pristine markup. Returns the masked string and the excised regions in order.
2109
+ * @param {string} template
2110
+ * @returns {{ masked: string, regions: string[] }}
2111
+ */
2112
+ function maskItemLoops(template) {
2113
+ const marker = "<div data-wd-loop-item=";
2114
+ /** @type {string[]} */
2115
+ const regions = [];
2116
+ let masked = "";
2117
+ let i = 0;
2118
+ for (;;) {
2119
+ const start = template.indexOf(marker, i);
2120
+ if (start === -1) return { masked: masked + template.slice(i), regions };
2121
+ const end = matchElement(template, start, "div");
2122
+ masked += template.slice(i, start) + `\x00WDLI${regions.length}\x00`;
2123
+ regions.push(template.slice(start, end));
2124
+ i = end;
2125
+ }
2126
+ }
2127
+
2128
+ /**
2129
+ * Restore the masked `data-wd-loop-item` regions into a filled row string.
2130
+ * @param {string} str
2131
+ * @param {string[]} regions
2132
+ * @returns {string}
2133
+ */
2134
+ function unmaskItemLoops(str, regions) {
2135
+ return regions.length ? str.replace(/\x00WDLI(\d+)\x00/g, (_, n) => regions[Number(n)]) : str;
2136
+ }
2137
+
1965
2138
  /**
1966
2139
  * @param {string} template
1967
2140
  * @param {string} itemKey
@@ -2507,7 +2680,13 @@ function parseSingleAction(expression, raw, ctx) {
2507
2680
  if (remove) {
2508
2681
  const operand = remove[2].trim();
2509
2682
  if (ctx.loopItem && operand === ctx.loopItem) {
2510
- if (!ctx.loopKey || resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
2683
+ // A nested (item-relative) loop has a loop item but no top-level state key —
2684
+ // its source is a path off the outer row, which the runtime's row-remove
2685
+ // can't target. Fail loud with the honest workaround.
2686
+ if (!ctx.loopKey) {
2687
+ throw new Error(`Button action "${raw}" can't delete a row of a nested (item-relative) loop in ${ctx.file}. Per-row "remove" needs a top-level :state/:store list; carry the row into one (cart += ${ctx.loopItem}) and remove it there.`);
2688
+ }
2689
+ if (resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
2511
2690
  throw new Error(`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`);
2512
2691
  }
2513
2692
  return { op: "remove", target: ctx.loopKey };
package/src/headers.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Security headers Darkmown emits on every delivery surface — the local
3
+ * `darkmown serve`/preview server (`src/statics.js`), Cloudflare Pages
4
+ * (`dist/_headers`, written by `src/builder.js`), and Vercel (`vercel.json`).
5
+ *
6
+ * `statics.js` and the generated `_headers` derive from the values here, so they
7
+ * never drift. `vercel.json` is the exception: Vercel reads it BEFORE the build
8
+ * runs, so its CSP strings and static-route list are hand-maintained copies, not
9
+ * build output. The `vercel-csp-routes` test cross-checks vercel.json against the
10
+ * built route manifest so a new static route can't silently miss the strict CSP.
11
+ *
12
+ * The CSP is derived from what the framework's own output actually emits
13
+ * (verify against built `dist/` HTML):
14
+ * - External script `/__wd/runtime.js` (same-origin) on reactive pages.
15
+ * - Inline `<script type="application/json" data-wd-state>` (state seed) and
16
+ * inline `<script type="speculationrules">` (prerender) — JSON/speculation
17
+ * blocks can't be nonce'd cleanly, so `script-src 'unsafe-inline'`.
18
+ * - The reactive runtime compiles validated expressions via `new Function`,
19
+ * so reactive pages additionally need `script-src 'unsafe-eval'`.
20
+ * - Inline `<style>` for view transitions — `style-src 'unsafe-inline'`.
21
+ * - `data:` favicon and remote images — `img-src 'self' data: https:`.
22
+ *
23
+ * Static pages (zero framework JS) still emit inline `<style>` and an inline
24
+ * `<script type="speculationrules">`, so they keep `'unsafe-inline'`, but they
25
+ * never call `new Function`, so their CSP drops `'unsafe-eval'`.
26
+ */
27
+
28
+ /**
29
+ * `connect-src` for the demo is `'self'`: the framework's own pages only
30
+ * `:fetch` same-origin endpoints. Apps whose `:fetch` targets a remote host
31
+ * must widen this (e.g. to `connect-src 'self' https://api.example.com` or
32
+ * `connect-src *`) by editing their `vercel.json` / `_headers` / serve config.
33
+ * @type {string}
34
+ */
35
+ const CONNECT_SRC = "connect-src 'self'";
36
+
37
+ /**
38
+ * Directives shared by both the static and reactive CSP. Order is cosmetic.
39
+ * @type {string[]}
40
+ */
41
+ const COMMON_CSP_DIRECTIVES = [
42
+ "default-src 'self'",
43
+ "style-src 'self' 'unsafe-inline'",
44
+ "img-src 'self' data: https:",
45
+ "font-src 'self'",
46
+ CONNECT_SRC,
47
+ "object-src 'none'",
48
+ "base-uri 'self'",
49
+ "frame-ancestors 'self'"
50
+ ];
51
+
52
+ /**
53
+ * Relaxed CSP for reactive pages: `new Function` needs `'unsafe-eval'`, the
54
+ * inline state/speculationrules JSON needs `'unsafe-inline'`.
55
+ * @type {string}
56
+ */
57
+ export const REACTIVE_CSP = [
58
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
59
+ ...COMMON_CSP_DIRECTIVES
60
+ ].join("; ");
61
+
62
+ /**
63
+ * Stricter CSP for static pages: no runtime means no `new Function`, so
64
+ * `'unsafe-eval'` is dropped. Inline `<style>`/`<script type=speculationrules>`
65
+ * keep `'unsafe-inline'` on script/style.
66
+ * @type {string}
67
+ */
68
+ export const STATIC_CSP = [
69
+ "script-src 'self' 'unsafe-inline'",
70
+ ...COMMON_CSP_DIRECTIVES
71
+ ].join("; ");
72
+
73
+ /**
74
+ * Baseline security headers applied to every HTML response, independent of the
75
+ * CSP. CSP is added per-route (static vs reactive) by the helpers below.
76
+ * @type {Record<string, string>}
77
+ */
78
+ export const BASE_SECURITY_HEADERS = {
79
+ "X-Content-Type-Options": "nosniff",
80
+ "Referrer-Policy": "strict-origin-when-cross-origin",
81
+ "X-Frame-Options": "SAMEORIGIN"
82
+ };
83
+
84
+ /**
85
+ * The full security-header set for one response, including the CSP variant.
86
+ * @param {boolean} reactive Whether the route ships the reactive runtime.
87
+ * @returns {Record<string, string>}
88
+ */
89
+ export function securityHeaders(reactive) {
90
+ return {
91
+ ...BASE_SECURITY_HEADERS,
92
+ "Content-Security-Policy": reactive ? REACTIVE_CSP : STATIC_CSP
93
+ };
94
+ }
package/src/runtime.js CHANGED
@@ -246,9 +246,15 @@ function loopKeyOf(item, counts) {
246
246
  * @returns {void}
247
247
  */
248
248
  function fillItem(node, item, meta = {}) {
249
+ // Nested item-relative loops (data-wd-loop-item) own their descendants' binds;
250
+ // when present, `skip` excludes a node living inside one so the outer row's
251
+ // text/meta/each-if/class passes never overwrite an inner row's values.
252
+ const inners = node.querySelectorAll("[data-wd-loop-item]");
253
+ const skip = inners.length ? (/** @type {Element} */ el) => el.closest("[data-wd-loop-item]") : () => false;
249
254
  // querySelectorAll skips <template> content, so this sees only the outermost
250
255
  // regions; recursing into each injected branch fills the rest.
251
256
  for (const region of node.querySelectorAll("[data-wd-each-if]")) {
257
+ if (skip(region)) continue;
252
258
  const expr = region.getAttribute("data-wd-if-expr");
253
259
  const m = region.getAttribute("data-wd-meta");
254
260
  const value = expr ? evalPredicate(expr, item) : (m ? meta[m] : getPath(item, region.getAttribute("data-wd-path")));
@@ -262,13 +268,17 @@ function fillItem(node, item, meta = {}) {
262
268
  ? [node, ...node.querySelectorAll("[data-wd-each]")]
263
269
  : [...node.querySelectorAll("[data-wd-each]")];
264
270
  for (const target of targets) {
271
+ if (skip(target)) continue;
265
272
  target.textContent = getPath(item, target.getAttribute("data-wd-path")) ?? "";
266
273
  }
267
274
  for (const marker of node.querySelectorAll("[data-wd-each-meta]")) {
275
+ if (skip(marker)) continue;
268
276
  marker.textContent = meta[marker.getAttribute("data-wd-each-meta") || ""] ?? "";
269
277
  }
270
278
  const classNodes = node.matches("[data-wd-each-class]") ? [node, ...node.querySelectorAll("[data-wd-each-class]")] : [...node.querySelectorAll("[data-wd-each-class]")];
271
- for (const el of classNodes) classToggle(el, el.getAttribute("data-wd-each-class"), item);
279
+ for (const el of classNodes) { if (!skip(el)) classToggle(el, el.getAttribute("data-wd-each-class"), item); }
280
+ // Reconcile nested loops last, against the now-settled outer row.
281
+ for (const region of inners) reconcile(region, getPath(item, region.getAttribute("data-wd-loop-item")));
272
282
  }
273
283
 
274
284
  /**
@@ -335,6 +345,75 @@ function renderIf(node) {
335
345
  for (const nested of output.querySelectorAll("[data-wd-if]")) renderIf(nested);
336
346
  }
337
347
 
348
+ /**
349
+ * Reconcile one loop region (top-level or nested) from a resolved row source:
350
+ * filter (`where`) → pipeline (sort/reverse/offset/limit) → @empty → keyed
351
+ * reconcile. Each surviving row is `fillItem`-ed, which itself recurses into any
352
+ * nested item-relative loops. Shared by the global pass and `fillItem`.
353
+ * @param {Element} region
354
+ * @param {any} rows Source list (state-resolved globally; item-relative inside fillItem).
355
+ * @returns {void}
356
+ */
357
+ function reconcile(region, rows) {
358
+ const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-template]"));
359
+ const out = region.querySelector("[data-wd-loop-out]");
360
+ if (!template || !out) return;
361
+ let list = Array.isArray(rows) ? rows.slice() : [];
362
+ const where = region.getAttribute("data-wd-loop-where");
363
+ if (where) {
364
+ const predicate = loopPredicate(where);
365
+ list = list.filter((/** @type {any} */ item) => {
366
+ try {
367
+ return predicate((/** @type {string | null} */ path) => getPath(item, path), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn);
368
+ } catch (error) {
369
+ warn(where, error);
370
+ return false;
371
+ }
372
+ });
373
+ }
374
+ list = pipeline(list, region);
375
+
376
+ // Empty branch: clone the [data-wd-loop-empty] template into the output.
377
+ const emptyTpl = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-empty]"));
378
+ if (!list.length && emptyTpl) {
379
+ if (out.getAttribute("data-wd-empty") !== "1") {
380
+ out.textContent = "";
381
+ for (const child of [...emptyTpl.content.children]) out.appendChild(child.cloneNode(true));
382
+ out.setAttribute("data-wd-empty", "1");
383
+ }
384
+ return;
385
+ }
386
+ if (out.getAttribute("data-wd-empty") === "1") { out.textContent = ""; out.removeAttribute("data-wd-empty"); }
387
+
388
+ /** @type {Map<string, WdRow>} */
389
+ const existing = new Map();
390
+ for (const child of [...out.children]) {
391
+ existing.set(child.getAttribute("data-wd-loop-key") || "", /** @type {WdRow} */ (child));
392
+ }
393
+ /** @type {Map<string, number>} */
394
+ const counts = new Map();
395
+ /** @type {Set<string>} */
396
+ const used = new Set();
397
+ const count = list.length;
398
+ for (let i = 0; i < count; i++) {
399
+ const item = list[i];
400
+ const key = loopKeyOf(item, counts);
401
+ let node = existing.get(key);
402
+ if (!node || used.has(key)) {
403
+ node = /** @type {WdRow} */ (template.content.firstElementChild?.cloneNode(true));
404
+ if (!node) continue;
405
+ node.setAttribute("data-wd-loop-key", key);
406
+ }
407
+ used.add(key);
408
+ fillItem(node, item, { index: i, number: i + 1, first: i === 0, last: i === count - 1, count });
409
+ node.__wdItem = item; /** let per-row actions resolve which row was clicked */
410
+ out.appendChild(node);
411
+ }
412
+ for (const [key, node] of existing) {
413
+ if (!used.has(key)) node.remove();
414
+ }
415
+ }
416
+
338
417
  /**
339
418
  * Synchronously render the whole document from current `state`:
340
419
  * computed → if-regions → keyed loop reconcile → text/input binds.
@@ -348,69 +427,14 @@ function renderNow() {
348
427
  // bindings (data-wd-each-class) are handled per item inside fillItem.
349
428
  for (const el of document.querySelectorAll("[data-wd-class]")) classToggle(el, el.getAttribute("data-wd-class"), undefined);
350
429
 
430
+ // Nested item-relative loops use data-wd-loop-item, so this top-level query
431
+ // skips them; fillItem reconciles those against their enclosing row.
351
432
  for (const region of document.querySelectorAll("[data-wd-loop]")) {
352
433
  const key = region.getAttribute("data-wd-loop");
353
434
  const data = region.getAttribute("data-wd-loop-data");
354
435
  /** Source may be a dotted path (e.g. team.members) read off state via getPath. */
355
436
  const dot = key ? key.indexOf(".") : -1;
356
- const rows = key ? (dot < 0 ? state[key] : getPath(state[key.slice(0, dot)], key.slice(dot + 1))) : (data ? JSON.parse(data) : []);
357
- const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-template]"));
358
- const out = region.querySelector("[data-wd-loop-out]");
359
- if (!template || !out) continue;
360
- let list = Array.isArray(rows) ? rows.slice() : [];
361
- const where = region.getAttribute("data-wd-loop-where");
362
- if (where) {
363
- const predicate = loopPredicate(where);
364
- list = list.filter((/** @type {any} */ item) => {
365
- try {
366
- return predicate((/** @type {string | null} */ path) => getPath(item, path), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn);
367
- } catch (error) {
368
- warn(where, error);
369
- return false;
370
- }
371
- });
372
- }
373
- list = pipeline(list, region);
374
-
375
- // Empty branch: clone the [data-wd-loop-empty] template into the output.
376
- const emptyTpl = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-empty]"));
377
- if (!list.length && emptyTpl) {
378
- if (out.getAttribute("data-wd-empty") !== "1") {
379
- out.textContent = "";
380
- for (const child of [...emptyTpl.content.children]) out.appendChild(child.cloneNode(true));
381
- out.setAttribute("data-wd-empty", "1");
382
- }
383
- continue;
384
- }
385
- if (out.getAttribute("data-wd-empty") === "1") { out.textContent = ""; out.removeAttribute("data-wd-empty"); }
386
-
387
- /** @type {Map<string, WdRow>} */
388
- const existing = new Map();
389
- for (const child of [...out.children]) {
390
- existing.set(child.getAttribute("data-wd-loop-key") || "", /** @type {WdRow} */ (child));
391
- }
392
- /** @type {Map<string, number>} */
393
- const counts = new Map();
394
- /** @type {Set<string>} */
395
- const used = new Set();
396
- const count = list.length;
397
- for (let i = 0; i < count; i++) {
398
- const item = list[i];
399
- const key = loopKeyOf(item, counts);
400
- let node = existing.get(key);
401
- if (!node || used.has(key)) {
402
- node = /** @type {WdRow} */ (template.content.firstElementChild?.cloneNode(true));
403
- if (!node) continue;
404
- node.setAttribute("data-wd-loop-key", key);
405
- }
406
- used.add(key);
407
- fillItem(node, item, { index: i, number: i + 1, first: i === 0, last: i === count - 1, count });
408
- node.__wdItem = item; /** let per-row actions resolve which row was clicked */
409
- out.appendChild(node);
410
- }
411
- for (const [key, node] of existing) {
412
- if (!used.has(key)) node.remove();
413
- }
437
+ reconcile(region, key ? (dot < 0 ? state[key] : getPath(state[key.slice(0, dot)], key.slice(dot + 1))) : (data ? JSON.parse(data) : []));
414
438
  }
415
439
 
416
440
  for (const node of document.querySelectorAll("[data-wd-bind]")) {
package/src/skin.js CHANGED
@@ -53,7 +53,16 @@ export function compileSkin(source) {
53
53
  /** @type {number | null} */
54
54
  let tokenIndent = null;
55
55
  /** @type {string[]} */
56
+ let tokenVars = [];
57
+ /** @type {string[]} */
56
58
  const rootVars = [];
59
+ /** @type {string[]} */
60
+ const darkVars = [];
61
+ // Manual-toggle variants: `tokens [data-theme=dark]` collects custom
62
+ // properties under an attribute-scoped `:root` so a `:store theme` toggle can
63
+ // flip them with no JS beyond writing the attribute.
64
+ /** @type {Map<string, string[]>} */
65
+ const attrVars = new Map();
57
66
 
58
67
  for (let i = 0; i < lines.length; i++) {
59
68
  const { indent, text } = lines[i];
@@ -61,14 +70,41 @@ export function compileSkin(source) {
61
70
  // the next line is indented deeper. Everything else is a declaration.
62
71
  const opensBlock = i + 1 < lines.length && lines[i + 1].indent > indent;
63
72
 
64
- if (text === "tokens" && opensBlock) {
73
+ // `tokens` opens a design-token block emitting `:root { --name: value }`.
74
+ // An optional modifier picks a variant: `tokens dark` collects into a
75
+ // `prefers-color-scheme: dark` override (OS-driven), while a bracketed
76
+ // `tokens [data-theme=dark]` scopes the override to a `:root` attribute for
77
+ // manual toggling. Both reuse the same `--name` properties.
78
+ // Capture the whole modifier (`.+`, not `\S+`) so a malformed bracketed
79
+ // modifier with internal spaces (`[data-theme=dark extra]`) is still routed
80
+ // to normalizeAttr's null fail-safe rather than slipping through as a bogus
81
+ // `tokens …` selector.
82
+ const tokenMatch = /^tokens(?:\s+(.+))?$/.exec(text);
83
+ if (tokenMatch && opensBlock) {
65
84
  tokenIndent = indent;
85
+ const modifier = tokenMatch[1];
86
+ if (!modifier) {
87
+ tokenVars = rootVars;
88
+ } else if (modifier === "dark") {
89
+ tokenVars = darkVars;
90
+ } else if (modifier.startsWith("[") && modifier.endsWith("]")) {
91
+ const attr = normalizeAttr(modifier);
92
+ if (attr) {
93
+ const selector = `:root${attr}`;
94
+ tokenVars = attrVars.get(selector) ?? [];
95
+ attrVars.set(selector, tokenVars);
96
+ } else {
97
+ tokenVars = rootVars; // malformed `[…]` modifier → fall back to default :root tokens
98
+ }
99
+ } else {
100
+ tokenVars = rootVars;
101
+ }
66
102
  continue;
67
103
  }
68
104
 
69
105
  if (tokenIndent !== null && indent > tokenIndent) {
70
106
  const [name, ...rest] = text.split(/\s+/);
71
- rootVars.push(` --${name}: ${rest.join(" ")};`);
107
+ tokenVars.push(` --${name}: ${rest.join(" ")};`);
72
108
  continue;
73
109
  }
74
110
 
@@ -96,10 +132,32 @@ export function compileSkin(source) {
96
132
  css.push(media ? `${media} { ${rule} }` : rule);
97
133
  }
98
134
 
135
+ // Prepend tokens ahead of selector rules. `unshift` reverses insertion order,
136
+ // so push the overrides first to land them after the `:root` defaults.
137
+ for (const [selector, vars] of attrVars) {
138
+ if (vars.length) css.unshift(`${selector} {\n${vars.join("\n")}\n}`);
139
+ }
140
+ if (darkVars.length) {
141
+ css.unshift(`@media (prefers-color-scheme: dark) {\n:root {\n${darkVars.join("\n")}\n}\n}`);
142
+ }
99
143
  if (rootVars.length) css.unshift(`:root {\n${rootVars.join("\n")}\n}`);
100
144
  return css.join("\n");
101
145
  }
102
146
 
147
+ /**
148
+ * Normalize a bracketed token modifier into a valid attribute selector,
149
+ * quoting the value so `[data-theme=dark]` becomes `[data-theme="dark"]`.
150
+ * Returns null for a malformed modifier (`[]`, `[a=b c]`, injection-shaped
151
+ * input) so the caller can fall back to default `:root` tokens instead of
152
+ * emitting a junk selector.
153
+ * @param {string} bracketed
154
+ * @returns {string | null}
155
+ */
156
+ function normalizeAttr(bracketed) {
157
+ const m = bracketed.match(/^\[\s*([^\]=\s]+)\s*=\s*"?([^\]"]*)"?\s*\]$/);
158
+ return m ? `[${m[1]}="${m[2]}"]` : null;
159
+ }
160
+
103
161
  /**
104
162
  * Combine a selector with its parent (handles `&` nesting and comma lists).
105
163
  * @param {string} text
package/src/statics.js CHANGED
@@ -1,6 +1,19 @@
1
1
  import fs from "node:fs";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
+ import { BASE_SECURITY_HEADERS, REACTIVE_CSP } from "./headers.js";
5
+
6
+ /**
7
+ * Security headers for an HTML response from the local server. The relaxed
8
+ * (reactive) CSP is a superset that satisfies both static and reactive pages,
9
+ * so the runtime server uses it for every HTML response; the build-time
10
+ * outputs (`vercel.json`, `dist/_headers`) tighten static routes per-path.
11
+ * @type {Record<string, string>}
12
+ */
13
+ const HTML_SECURITY_HEADERS = {
14
+ ...BASE_SECURITY_HEADERS,
15
+ "Content-Security-Policy": REACTIVE_CSP
16
+ };
4
17
 
5
18
  /**
6
19
  * Serve a built file for a request URL out of `distRoot`, or a 404 page.
@@ -12,7 +25,7 @@ import path from "node:path";
12
25
  export function serve(distRoot, url, res) {
13
26
  const file = resolvePublicFile(distRoot, url || "/");
14
27
  if (!file || !fs.existsSync(file)) {
15
- res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
28
+ res.writeHead(404, { "content-type": "text/html; charset=utf-8", ...HTML_SECURITY_HEADERS });
16
29
  const notFound = path.join(distRoot, "404.html");
17
30
  if (fs.existsSync(notFound)) {
18
31
  res.end(fs.readFileSync(notFound));
@@ -21,7 +34,11 @@ export function serve(distRoot, url, res) {
21
34
  res.end("<h1>Not found</h1><p>This route is hidden or has not been created.</p>");
22
35
  return;
23
36
  }
24
- res.writeHead(200, { "content-type": contentType(file) });
37
+ const type = contentType(file);
38
+ const headers = type.startsWith("text/html")
39
+ ? { "content-type": type, ...HTML_SECURITY_HEADERS }
40
+ : { "content-type": type };
41
+ res.writeHead(200, headers);
25
42
  fs.createReadStream(file).pipe(res);
26
43
  }
27
44