@zvndev/darkmown 0.18.0 → 0.20.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,28 @@
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.20.0 — 2026-06-26
6
+
7
+ Engineering hardening: a real programmatic API, plus a gold-standard pass on tooling, tests, and internals. No behavior change for existing sites — the runtime is byte-for-byte the same (~5.8 KB gzipped).
8
+
9
+ - **Programmatic API.** `@zvndev/darkmown` now ships a proper entry point: `import { buildSite, compilePage, compileDocument, parseFrontmatter, escapeHtml, … } from "@zvndev/darkmown"` resolves through an `exports` map and a `src/index.js` barrel that mirrors the public surface already declared in `types/index.d.ts`. The package was CLI-only before; the `darkmown` CLI is unchanged.
10
+ - **Compiler internals split.** The ~2,900-line `src/compiler.js` is now a `src/compiler/` folder along its existing seams (page shell, frontmatter, includes, interpolation, predicates, markdown plugin, loops, directives, body). Public exports and output are identical — `src/compiler.js` stays as a thin re-export barrel. Purely internal.
11
+ - **Sharper compile errors.** Malformed-directive errors now report `file:line` (matching the unclosed-block errors that already did), and action-literal failures name the offending page. Easier to trace a mistake back to its source.
12
+ - **Lint + format with Biome.** Added Biome (one binary, lint + format) with `npm run lint` / `npm run format:check`, a `lint` CI job, and a husky + lint-staged pre-commit that checks staged files. `src/runtime.js` is excluded from the formatter so the byte budget is never moved by a reformat. Style commands are documented in CONTRIBUTING.md.
13
+ - **100% line coverage, CI-gated.** The test suite now covers every line of `src/`, and the coverage gate is raised from 90% to 100%. Covering the CLI and dev-server paths surfaced and fixed two real bugs: symlinked-bin entry-point detection and a dev-server response that wrote headers before reading the file.
14
+ - **Runtime size trend.** A committed `.size-snapshot.json` baseline lets CI report the gzipped-runtime delta on every change, alongside the existing hard < 6 KB gate.
15
+ - **Fixed a production-breaking `vercel.json`.** An invalid `comment` property was rejected by Vercel's strict schema and failed every deploy before install; removed, with a regression test that rejects any non-allowlisted key in a `headers[]` rule.
16
+
17
+ ## 0.19.0 — 2026-06-25
18
+
19
+ Post-review sprint: deeper reactive loops, hardened deploys, and OS-aware theming.
20
+
21
+ - **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.
22
+ - **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.
23
+ - **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.
24
+ - **Runtime test backfill.** Added runtime/compiler tests covering the new reactive-loop nesting and the security-header and theming output.
25
+ - **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.
26
+
5
27
  ## 0.18.0 — 2026-06-24
6
28
 
7
29
  Navigation goes instant and flash-free, and images are hardened at compile time.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  **[darkmown.com](https://darkmown.com)** · Markdown that runs.
9
9
 
10
- Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~5.7 KB gzipped, CI-enforced under 6 KB.
10
+ Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~5.8 KB gzipped, CI-enforced under 6 KB.
11
11
 
12
12
  ## Quick start
13
13
 
@@ -52,7 +52,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
52
52
  - Files or folders starting with `.`, `-`, or `_` are hidden from routing.
53
53
  - `site/_` is the include shelf for `@include /name.wd`.
54
54
  - Matching `page.skin` and `page.js` colocate styling and behavior by basename.
55
- - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~5.7 KB gzipped, CI-enforced under 6 KB).
55
+ - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~5.8 KB gzipped, CI-enforced under 6 KB).
56
56
  - Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
57
57
 
58
58
  ## Interpolation
@@ -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.20.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -10,26 +10,41 @@
10
10
  "bin": {
11
11
  "darkmown": "./src/cli.js"
12
12
  },
13
+ "main": "./src/index.js",
14
+ "exports": {
15
+ ".": "./src/index.js",
16
+ "./package.json": "./package.json"
17
+ },
13
18
  "types": "./types/index.d.ts",
14
19
  "scripts": {
15
20
  "build": "node src/cli.js build",
16
21
  "runtime:size": "node -e \"const fs=require('fs'),zlib=require('zlib'); const size=zlib.gzipSync(fs.readFileSync('dist/__wd/runtime.js')).length; console.log('shipped runtime gzipped: '+size+' bytes (budget: 6144)'); if (size >= 6144) process.exit(1)\"",
17
22
  "dev": "node src/cli.js dev",
18
23
  "test": "node --test tests/*.test.js",
19
- "test:cov": "node scripts/test-coverage-gate.mjs 90",
24
+ "test:cov": "node scripts/test-coverage-gate.mjs 100",
20
25
  "test:e2e": "playwright test",
21
26
  "test:extension": "npm --prefix editors/vscode ci && npm --prefix editors/vscode test",
22
27
  "pack:extension": "cd editors/vscode && npx --yes @vscode/vsce package",
23
28
  "typecheck": "tsc --noEmit",
24
29
  "types": "tsc -p tsconfig.types.json",
25
30
  "smoke": "node scripts/smoke-consumer.mjs",
31
+ "lint": "biome check .",
32
+ "format": "biome format --write .",
33
+ "format:check": "biome format .",
34
+ "size:check": "node scripts/size-check.mjs",
35
+ "prepare": "husky || true",
26
36
  "prepublish:check": "npm run typecheck && npm run test:cov && npm run build && npm run runtime:size && npm audit --audit-level=high && npm run smoke",
27
- "prepublishOnly": "npm run prepublish:check"
37
+ "prepublishOnly": "npm run prepublish:check",
38
+ "og": "node scripts/og-image.mjs"
39
+ },
40
+ "lint-staged": {
41
+ "*.{js,json}": "biome check --no-errors-on-unmatched --files-ignore-unknown=true"
28
42
  },
29
43
  "files": [
30
44
  "src",
31
45
  "types",
32
46
  "site",
47
+ "!site/_/og.png",
33
48
  "docs/cli.md",
34
49
  "docs/spec-alignment.md",
35
50
  "AGENTS.md",
@@ -46,10 +61,13 @@
46
61
  "markdown-it": "^14.2.0"
47
62
  },
48
63
  "devDependencies": {
64
+ "@biomejs/biome": "^2.5.1",
49
65
  "@playwright/test": "^1.49.0",
50
66
  "@types/markdown-it": "^14.1.2",
51
- "@types/node": "^22.19.21",
52
- "typescript": "^5.9.3"
67
+ "@types/node": "^26.0.0",
68
+ "husky": "^9.1.7",
69
+ "lint-staged": "^16.4.0",
70
+ "typescript": "^6.0.3"
53
71
  },
54
72
  "repository": {
55
73
  "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
 
@@ -59,7 +60,9 @@ export function buildSite(cwd = process.cwd()) {
59
60
  file: path.relative(cwd, route.file).replaceAll(path.sep, "/"),
60
61
  assets: {
61
62
  skins: [...page.assets.skins],
62
- scripts: page.assets.runtime ? ["/__wd/runtime.js", ...page.assets.scripts] : [...page.assets.scripts],
63
+ scripts: page.assets.runtime
64
+ ? ["/__wd/runtime.js", ...page.assets.scripts]
65
+ : [...page.assets.scripts],
63
66
  runtime: page.assets.runtime
64
67
  }
65
68
  });
@@ -72,6 +75,12 @@ export function buildSite(cwd = process.cwd()) {
72
75
 
73
76
  fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
74
77
 
78
+ // Cloudflare Pages reads dist/_headers for security headers (Vercel reads
79
+ // vercel.json; the local server adds them in src/statics.js). Static routes
80
+ // get the stricter CSP (no 'unsafe-eval'); reactive routes get the relaxed
81
+ // one. A catch-all keeps assets and any future path covered.
82
+ fs.writeFileSync(path.join(paths.distRoot, "_headers"), renderCloudflareHeaders(manifest));
83
+
75
84
  // Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
76
85
  // sees every emitted route/framework file already on disk.
77
86
  emitPageAssets(paths);
@@ -94,13 +103,58 @@ export function buildSite(cwd = process.cwd()) {
94
103
  * @returns {string}
95
104
  */
96
105
  export function stripRuntimeComments(source) {
97
- return source
98
- // JSDoc blocks — type annotations the browser never needs.
99
- .replace(/\/\*\*[\s\S]*?\*\//g, "")
100
- // Whole-line `//` comments — developer notes that don't belong in the download.
101
- .replace(/^[ \t]*\/\/.*$/gm, "")
102
- // Drop the now blank/whitespace-only lines the removals leave behind.
103
- .replace(/^[ \t]*\n/gm, "");
106
+ return (
107
+ source
108
+ // JSDoc blocks — type annotations the browser never needs.
109
+ .replace(/\/\*\*[\s\S]*?\*\//g, "")
110
+ // Whole-line `//` comments — developer notes that don't belong in the download.
111
+ .replace(/^[ \t]*\/\/.*$/gm, "")
112
+ // Drop the now blank/whitespace-only lines the removals leave behind.
113
+ .replace(/^[ \t]*\n/gm, "")
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Render the Cloudflare Pages `_headers` file from the route manifest.
119
+ *
120
+ * Cloudflare applies every matching block in order; when blocks set the same
121
+ * header, the later block wins. So we emit the catch-all `/*` first (baseline
122
+ * security headers + the relaxed reactive CSP that satisfies every page), then
123
+ * one block per static route that overrides `Content-Security-Policy` with the
124
+ * stricter, eval-free CSP. Reactive routes need no override — the catch-all
125
+ * already carries the relaxed CSP.
126
+ *
127
+ * Clean URLs mean a route like `/docs/` is served at both `/docs` and `/docs/`,
128
+ * so each static route emits a path glob (`/docs`, `/docs/*`) that covers both.
129
+ * @param {RouteManifestEntry[]} manifest
130
+ * @returns {string}
131
+ */
132
+ export function renderCloudflareHeaders(manifest) {
133
+ const baseLines = Object.entries(BASE_SECURITY_HEADERS).map(
134
+ ([name, value]) => ` ${name}: ${value}`
135
+ );
136
+ const blocks = [["/*", [...baseLines, ` Content-Security-Policy: ${REACTIVE_CSP}`]]];
137
+
138
+ for (const entry of manifest) {
139
+ if (entry.assets.runtime) continue; // reactive routes keep the catch-all (relaxed) CSP
140
+ for (const pattern of cloudflarePathPatterns(entry.route)) {
141
+ blocks.push([pattern, [` Content-Security-Policy: ${STATIC_CSP}`]]);
142
+ }
143
+ }
144
+
145
+ return `${blocks.map(([pattern, lines]) => [pattern, ...lines].join("\n")).join("\n\n")}\n`;
146
+ }
147
+
148
+ /**
149
+ * Cloudflare `_headers` path patterns covering a clean-URL route. `/` →
150
+ * `["/", "/index.html"]`; `/docs/` → `["/docs", "/docs/*"]`.
151
+ * @param {string} route Route path from the manifest (always trailing-slashed).
152
+ * @returns {string[]}
153
+ */
154
+ function cloudflarePathPatterns(route) {
155
+ if (route === "/") return ["/", "/index.html"];
156
+ const trimmed = route.replace(/\/$/, ""); // "/docs/" -> "/docs"
157
+ return [trimmed, `${trimmed}/*`];
104
158
  }
105
159
 
106
160
  /**
@@ -119,7 +173,7 @@ function defaultNotFoundHtml() {
119
173
  "<body>",
120
174
  "<main>",
121
175
  "<h1>Page not found</h1>",
122
- "<p>That route does not exist. <a href=\"/\">Back to home</a>.</p>",
176
+ '<p>That route does not exist. <a href="/">Back to home</a>.</p>',
123
177
  "</main>",
124
178
  "</body>",
125
179
  "</html>",
@@ -193,7 +247,9 @@ function emitPageAssets(paths) {
193
247
  if (hasHiddenPathSegment(rel) || fs.lstatSync(file).isSymbolicLink()) continue;
194
248
  const out = path.join(paths.distRoot, rel);
195
249
  if (fs.existsSync(out)) {
196
- console.warn(`hint: page asset "${rel}" skipped — a built route already emits /${rel.replaceAll(path.sep, "/")}`);
250
+ console.warn(
251
+ `hint: page asset "${rel}" skipped — a built route already emits /${rel.replaceAll(path.sep, "/")}`
252
+ );
197
253
  continue;
198
254
  }
199
255
  fs.mkdirSync(path.dirname(out), { recursive: true });