@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 +22 -0
- package/README.md +60 -3
- package/SECURITY.md +39 -4
- package/docs/spec-alignment.md +9 -0
- package/package.json +23 -5
- package/site/_/nav.wd +1 -0
- package/site/pages/loops.wd +22 -0
- package/site/pages/theme.skin +53 -0
- package/site/pages/theme.wd +58 -0
- package/src/builder.js +66 -10
- package/src/cli.js +216 -77
- package/src/compiler/body.js +432 -0
- package/src/compiler/context.js +160 -0
- package/src/compiler/directives.js +1149 -0
- package/src/compiler/frontmatter.js +117 -0
- package/src/compiler/includes.js +116 -0
- package/src/compiler/index.js +22 -0
- package/src/compiler/interpolation.js +189 -0
- package/src/compiler/loops.js +644 -0
- package/src/compiler/markdown.js +233 -0
- package/src/compiler/page.js +267 -0
- package/src/compiler/predicates.js +281 -0
- package/src/compiler.js +26 -2753
- package/src/dev.js +3 -1
- package/src/headers.js +93 -0
- package/src/index.js +22 -0
- package/src/router.js +3 -1
- package/src/runtime.js +83 -59
- package/src/scaffold.js +158 -131
- package/src/skin.js +62 -3
- package/src/statics.js +19 -2
- package/site/_/og.png +0 -0
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.
|
|
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.
|
|
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
|
|
34
|
+
## The #1 footgun: raw HTML passthrough (`html: true`)
|
|
35
35
|
|
|
36
|
-
|
|
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
|
-
-
|
|
39
|
-
|
|
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).
|
package/docs/spec-alignment.md
CHANGED
|
@@ -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.
|
|
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
|
|
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": "^
|
|
52
|
-
"
|
|
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
package/site/pages/loops.wd
CHANGED
|
@@ -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
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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(
|
|
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 });
|