@zvndev/darkmown 0.17.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 +19 -0
- package/README.md +61 -2
- package/SECURITY.md +39 -4
- package/docs/spec-alignment.md +15 -1
- package/package.json +4 -3
- package/site/_/nav.wd +1 -0
- package/site/pages/data.wd +1 -0
- package/site/pages/docs/index.wd +9 -0
- package/site/pages/index.wd +1 -0
- package/site/pages/loops.wd +23 -0
- package/site/pages/markdown.md +1 -0
- package/site/pages/reactive.wd +1 -0
- package/site/pages/store.wd +1 -0
- package/site/pages/theme.skin +53 -0
- package/site/pages/theme.wd +58 -0
- package/src/builder.js +50 -0
- package/src/compiler.js +317 -27
- package/src/headers.js +94 -0
- package/src/runtime.js +83 -59
- package/src/skin.js +60 -2
- package/src/statics.js +19 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
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
|
+
|
|
15
|
+
## 0.18.0 — 2026-06-24
|
|
16
|
+
|
|
17
|
+
Navigation goes instant and flash-free, and images are hardened at compile time.
|
|
18
|
+
|
|
19
|
+
- **Prerendered, gap-free navigation.** `transitions: true` now also emits a declarative `<script type="speculationrules">` that **prerenders** same-origin links on hover/pointerdown (eagerness `moderate`). The next page is fully rendered before the click, so activation is instant — eliminating the white render-gap flash that plain navigation (and even prefetch, which only warms the cache) leaves for the view transition to paper over. It is browser-interpreted JSON, not framework runtime JS, so static pages stay zero-JS; unsupported browsers (or those with preloading disabled) ignore it and navigate normally. Mark a link `{.no-prefetch}` to opt it out; `rel=nofollow` links are never speculated. (Chrome disables prerendering while DevTools is open — verify on the built site with DevTools closed.)
|
|
20
|
+
- **Directional view transition.** The root transition is no longer the UA cross-fade, which left both pages superimposed at ~50 % opacity mid-navigation — a visible double-exposure ghost. `transitions: true` now emits a short directional fade+slide (old lifts up and out, new rises up and in, 200 ms eased) so pages move past each other instead of stacking. Honors `prefers-reduced-motion`.
|
|
21
|
+
- **Compile-time image hardening.** Every `<img>` is stamped with its intrinsic `width`/`height` (read from the file on disk via `image-size`, a new build-time dependency — no runtime cost), `decoding="async"`, and a load-priority split: the first image stays eager with `fetchpriority="high"` (the LCP candidate), the rest get `loading="lazy"`. This removes the layout-shift "jump" as images decode and stops below-the-fold images competing for bandwidth. Author-set attributes are never overwritten; remote or unreadable sources degrade gracefully (no dimensions, no error).
|
|
22
|
+
- All three are compile-time only — the shipped reactive runtime is unchanged (still ~5.8 KB gzipped, under the 6 KB budget). The demo site (`site/pages/`) now opts into `transitions: true` to dogfood the navigation.
|
|
23
|
+
|
|
5
24
|
## 0.17.0 — 2026-06-23
|
|
6
25
|
|
|
7
26
|
Multi-select and single-choice form groups: **`:checkbox` and `:radio`**.
|
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ tags: [sales, revenue, "q1, q2"]
|
|
|
82
82
|
|
|
83
83
|
Three keys also drive the document `<head>`: `title` sets `<title>`, `description` adds the meta description plus Open Graph / Twitter tags, and `image` (an absolute URL) sets the social-share preview (`og:image` / `twitter:image` and a `summary_large_image` card).
|
|
84
84
|
|
|
85
|
-
`transitions: true` opts a page into **
|
|
85
|
+
`transitions: true` opts a page into **instant, flash-free navigation** — zero JavaScript, all declarative. It emits a directional fade+slide **view transition** for the page swap (old lifts up and out, new rises up and in — replacing the default cross-fade, which left both pages ghosted at ~50 % opacity mid-navigation), plus a **`<script type="speculationrules">` prerender** hint that renders the next same-origin page on hover/pointerdown so the click activates an already-painted page (no white render-gap flash). It honors `prefers-reduced-motion`. Only same-origin pages that both opt in transition; browsers without support — or with page-preloading disabled — navigate normally. Off by default; opt out with `transitions: false`. Mark a link `{.no-prefetch}` to exclude it from prerendering. (Chrome disables prerendering while DevTools is open, so test the built site with DevTools closed.)
|
|
86
86
|
|
|
87
87
|
## Static assets
|
|
88
88
|
|
|
@@ -93,6 +93,8 @@ Images, fonts, icons, and other non-page files live on the include shelf at `sit
|
|
|
93
93
|
|
|
94
94
|
Reference them with a normal URL: ``. The preview/`serve` layer returns the correct `Content-Type` per extension.
|
|
95
95
|
|
|
96
|
+
**Images are hardened at compile time.** Every `<img>` the compiler emits is stamped with its intrinsic `width`/`height` (read from the source file, so the browser reserves space and the page doesn't reflow as images decode), `decoding="async"`, and a load-priority split — the first image on the page stays eager with `fetchpriority="high"` (the LCP candidate), the rest get `loading="lazy"`. Author-set attributes always win, and remote or unreadable sources just skip the dimensions. The compiler **measures**, it doesn't resize — keep source images web-sized (modern formats, sensible dimensions).
|
|
97
|
+
|
|
96
98
|
**Or colocate an asset next to the page that uses it.** Any non-page file under `site/pages/` (anything but `.md`/`.wd` routes and the colocated `.skin`/`.js`) is copied to `dist/` at its own path, so a clean relative URL just works:
|
|
97
99
|
|
|
98
100
|
- `site/pages/logo.svg` → `dist/logo.svg` → ``
|
|
@@ -238,6 +240,34 @@ A `:button` inside a reactive `@loop` can act on its own row. `cart += product`
|
|
|
238
240
|
|
|
239
241
|
That is a full add-to-cart / remove-line flow — and a to-do list with delete — in plain Markdown, no JavaScript.
|
|
240
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
|
+
|
|
241
271
|
## Sections
|
|
242
272
|
|
|
243
273
|
```wd
|
|
@@ -526,6 +556,26 @@ A trailing `{.class .class #id}` attaches classes / an id to the inline element
|
|
|
526
556
|
|
|
527
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 `#`.
|
|
528
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
|
+
|
|
529
579
|
## The escape hatch
|
|
530
580
|
|
|
531
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`.
|
|
@@ -546,10 +596,19 @@ Darkmown is a **trusted-author** site generator: you compile content you wrote,
|
|
|
546
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.
|
|
547
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).
|
|
548
598
|
|
|
549
|
-
> **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)).
|
|
550
600
|
|
|
551
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.
|
|
552
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
|
+
|
|
553
612
|
## Spec status
|
|
554
613
|
|
|
555
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
|
@@ -79,6 +79,20 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
|
|
|
79
79
|
- **Interpolation in link/image destinations:** `{ name.path }` now resolves inside a markdown destination — `[{ it.label }]({ it.url })`, `` — so a build-time `@loop` can drive `href`/`src` and the page stays static. Only build-time (static-scope) values substitute; reactive `:state` is left untouched (it cannot live in a destination).
|
|
80
80
|
- **Inline attributes (`{.class #id}`):** a trailing attribute block attaches classes / an id to the inline element before it (link, image, emphasis, code), so a link can be styled as a button without a wrapper container. The block must follow with no space; it never collides with `{ name }` interpolation (which always starts with a name, not `.`/`#`).
|
|
81
81
|
|
|
82
|
+
## Stage 14 additions (2026-06-24, v0.18.0)
|
|
83
|
+
|
|
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
|
+
- **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
|
+
|
|
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
|
+
|
|
82
96
|
## Stage 13 additions (2026-06-23, v0.17.0)
|
|
83
97
|
|
|
84
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.
|
|
@@ -107,7 +121,7 @@ The compiler recognizes three directives — `:note`, `:try`, and `:sprint` —
|
|
|
107
121
|
## Still missing from the full vision
|
|
108
122
|
|
|
109
123
|
- First-party server runtime (`site/api/`), HTML-fragment `swap` semantics, and cart server sync — parked behind the adapter decision until a real project needs them.
|
|
110
|
-
- **View transitions:** named/typed transitions (`view-transition-name`, per-element morphs) and `@starting-style` emission on top of the
|
|
124
|
+
- **View transitions:** named/typed transitions (`view-transition-name`, per-element morphs) and `@starting-style` emission, on top of the directional fade + speculation-rules prerender shipped in 0.18.0 (see Stage 4 / Stage 14).
|
|
111
125
|
- Richer diagnostics and package-consumer QA in regular CI. Editor tooling exists as a source-installable VS Code extension; public Marketplace/Open VSX distribution remains a launch/distribution follow-up.
|
|
112
126
|
|
|
113
127
|
## Current claim
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -42,13 +42,14 @@
|
|
|
42
42
|
"node": ">=20"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"image-size": "^1.2.1",
|
|
45
46
|
"markdown-it": "^14.2.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@playwright/test": "^1.49.0",
|
|
49
50
|
"@types/markdown-it": "^14.1.2",
|
|
50
|
-
"@types/node": "^
|
|
51
|
-
"typescript": "^
|
|
51
|
+
"@types/node": "^26.0.0",
|
|
52
|
+
"typescript": "^6.0.3"
|
|
52
53
|
},
|
|
53
54
|
"repository": {
|
|
54
55
|
"type": "git",
|
package/site/_/nav.wd
CHANGED
package/site/pages/data.wd
CHANGED
package/site/pages/docs/index.wd
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Darkmown docs
|
|
3
3
|
description: Learn Darkmown routing, directives, state, loops, forms, and the static-to-reactive model.
|
|
4
|
+
transitions: true
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
@include /nav.wd
|
|
@@ -164,6 +165,14 @@ tags: [sales, revenue, "q1, q2"]
|
|
|
164
165
|
|
|
165
166
|
`{ meta.title }` prints a scalar and `{ meta.tags }` joins an array with `, `. You can loop a frontmatter array at build time — `@loop meta.tags into tag` — and the page stays static. Arrays are inline flow only (`[a, b]`); quoted items keep their commas.
|
|
166
167
|
|
|
168
|
+
## Instant navigation
|
|
169
|
+
|
|
170
|
+
Add `transitions: true` to a page's frontmatter for **instant, flash-free navigation** — zero JavaScript. It emits a directional fade+slide view transition for the page swap and a `<script type="speculationrules">` prerender hint, so the browser renders the next same-origin page on hover/pointerdown and the click activates an already-painted page (no white render-gap). It honors `prefers-reduced-motion`, and only same-origin pages that both opt in transition. Off by default; opt out with `transitions: false`, or exclude a single link from prerendering with `{.no-prefetch}`. (Chrome turns prerendering off while DevTools is open — test the built site with DevTools closed.)
|
|
171
|
+
|
|
172
|
+
## Images
|
|
173
|
+
|
|
174
|
+
You don't size images by hand. Every `<img>` the compiler emits gets its intrinsic `width`/`height` read from the source file (so the page doesn't reflow as images decode), `decoding="async"`, and a priority split — the first image stays eager with `fetchpriority="high"`, the rest `loading="lazy"`. Anything you set yourself wins. The compiler measures, it doesn't resize, so keep source images web-sized.
|
|
175
|
+
|
|
167
176
|
## Sections
|
|
168
177
|
|
|
169
178
|
`::: section #id .class` opens a container and `:::` closes it. Sections scope state: a `:state` declared inside a section belongs to that section, so two sections can both declare `count` without colliding. Bindings and buttons resolve to the nearest scope.
|
package/site/pages/index.wd
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
title: Darkmown — Markdown that runs, not Markdown that needs React
|
|
3
3
|
description: A live store widget — search filter, add-to-cart, running total — written as one Markdown file that runs. The same outcome in Markdoc needs a React component, hydration, and a build step. Compare them side by side.
|
|
4
4
|
image: https://darkmown.com/__wd/media/og.png
|
|
5
|
+
transitions: true
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
@include /nav.wd
|
package/site/pages/loops.wd
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Loop ergonomics
|
|
3
|
+
transitions: true
|
|
3
4
|
---
|
|
4
5
|
|
|
5
6
|
@include /nav.wd
|
|
@@ -83,4 +84,26 @@ No sort key needed — `reverse` walks the list back to front:
|
|
|
83
84
|
- { r.label }
|
|
84
85
|
@endloop
|
|
85
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
|
+
|
|
86
109
|
</main>
|
package/site/pages/markdown.md
CHANGED
package/site/pages/reactive.wd
CHANGED
package/site/pages/store.wd
CHANGED
|
@@ -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}
|