@zvndev/darkmown 1.5.0 → 2.0.1

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.
Files changed (95) hide show
  1. package/AGENTS.md +18 -4
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +49 -19
  4. package/SECURITY.md +21 -18
  5. package/docs/cli.md +5 -1
  6. package/docs/spec-alignment.md +21 -7
  7. package/package.json +3 -2
  8. package/site/_/base.skin +8 -0
  9. package/site/_/base.wd +4 -0
  10. package/site/_/feature-card.wd +4 -0
  11. package/site/_/footer.wd +5 -1
  12. package/site/_/nav.wd +4 -0
  13. package/site/_/scoped-promo.wd +4 -0
  14. package/site/_/scoped-stat.wd +4 -0
  15. package/site/pages/404.wd +1 -0
  16. package/site/pages/app.wd +2 -1
  17. package/site/pages/blog/collections-and-pagination.skin +127 -0
  18. package/site/pages/blog/drafts-that-never-leak.skin +127 -0
  19. package/site/pages/blog/index.wd +1 -0
  20. package/site/pages/blog/shipping-feeds.skin +127 -0
  21. package/site/pages/blog/typed-frontmatter.skin +127 -0
  22. package/site/pages/blog/unfinished-thoughts.skin +127 -0
  23. package/site/pages/blog/zero-js-by-default.md +1 -1
  24. package/site/pages/blog/zero-js-by-default.skin +127 -0
  25. package/site/pages/carousel/index.wd +1 -0
  26. package/site/pages/compass/index.wd +1 -0
  27. package/site/pages/data.wd +3 -0
  28. package/site/pages/docs/-relative-note.wd +4 -0
  29. package/site/pages/docs/index.skin +27 -0
  30. package/site/pages/docs/index.wd +85 -14
  31. package/site/pages/folio/-style.wd +4 -0
  32. package/site/pages/folio/cart.wd +1 -0
  33. package/site/pages/folio/index.wd +1 -0
  34. package/site/pages/forge/index.wd +1 -0
  35. package/site/pages/highlight/index.wd +1 -0
  36. package/site/pages/index.js +19 -0
  37. package/site/pages/index.skin +46 -1
  38. package/site/pages/index.wd +6 -3
  39. package/site/pages/interactions/index.wd +1 -0
  40. package/site/pages/ledger/index.wd +1 -0
  41. package/site/pages/loops.wd +1 -0
  42. package/site/pages/media/index.wd +1 -0
  43. package/site/pages/pulse/index.wd +1 -0
  44. package/site/pages/reactive.wd +1 -0
  45. package/site/pages/scoped/index.wd +1 -0
  46. package/site/pages/showcase/index.wd +2 -1
  47. package/site/pages/store.wd +1 -0
  48. package/site/pages/theme.wd +1 -0
  49. package/src/builder.js +444 -65
  50. package/src/cli.js +96 -18
  51. package/src/compiler/actions.js +303 -0
  52. package/src/compiler/body.js +36 -24
  53. package/src/compiler/context.js +65 -6
  54. package/src/compiler/directives.js +31 -1470
  55. package/src/compiler/fetch.js +159 -0
  56. package/src/compiler/format.js +17 -4
  57. package/src/compiler/forms.js +456 -0
  58. package/src/compiler/frontmatter.js +10 -5
  59. package/src/compiler/includes.js +75 -4
  60. package/src/compiler/index.js +15 -3
  61. package/src/compiler/loops.js +85 -23
  62. package/src/compiler/markdown.js +65 -15
  63. package/src/compiler/media.js +133 -0
  64. package/src/compiler/page.js +88 -77
  65. package/src/compiler/state.js +168 -0
  66. package/src/compiler/structure.js +399 -0
  67. package/src/dev.js +76 -0
  68. package/src/headers.js +56 -17
  69. package/src/router.js +12 -1
  70. package/src/runtime.js +5 -3
  71. package/src/statics.js +63 -12
  72. package/src/templates/blog/README.md +13 -4
  73. package/src/templates/blog/site/_/nav.wd +7 -6
  74. package/src/templates/blog/site/pages/index.skin +10 -4
  75. package/src/templates/blog/site/pages/index.wd +11 -6
  76. package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
  77. package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
  78. package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
  79. package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
  80. package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
  81. package/src/templates/dashboard/site/_/nav.wd +7 -6
  82. package/src/templates/dashboard/site/pages/index.skin +7 -4
  83. package/src/templates/dashboard/site/pages/index.wd +2 -2
  84. package/src/templates/landing/site/_/nav.wd +7 -6
  85. package/src/templates/landing/site/pages/index.skin +13 -4
  86. package/src/templates/landing/site/pages/index.wd +22 -8
  87. package/src/templates/starter/site/_/nav.wd +8 -7
  88. package/src/templates/starter/site/pages/about.md +8 -2
  89. package/src/templates/starter/site/pages/about.skin +44 -0
  90. package/src/templates/starter/site/pages/index.skin +10 -6
  91. package/src/templates/starter/site/pages/index.wd +2 -2
  92. package/src/templates/store/site/_/nav.wd +7 -6
  93. package/src/templates/store/site/pages/index.skin +7 -4
  94. package/src/templates/store/site/pages/index.wd +2 -2
  95. package/src/templates/blog/site/_/posts.json +0 -16
package/AGENTS.md CHANGED
@@ -7,7 +7,20 @@ Darkmown is a Markdown-native web framework. You build sites by writing `.md` an
7
7
  - `.md` files are plain CommonMark. Directives stay inert text.
8
8
  - `.wd` files are the same Markdown **plus directives** (state, loops, conditionals, includes, forms).
9
9
  - To make a `.md` page interactive, **rename it to `.wd`**. ⚠️ Renaming means the old `.md` is **gone** — never leave both `index.md` and `index.wd`, that is a fatal `Duplicate route` build error. Upgrade = delete the `.md`, create the `.wd`.
10
- - Static pages ship **zero** JavaScript; a page loads the ~7.4 KB gzipped runtime (CI-enforced under 8 KB) only if it declares reactive behavior.
10
+ - Static pages ship **zero** JavaScript; a page loads the ~7.5 KB gzipped runtime (CI-enforced under 8 KB) only if it declares reactive behavior.
11
+
12
+ ### ⚠️ Raw HTML is escaped by default (since 2.0.0)
13
+
14
+ Raw HTML you write in a `.md`/`.wd` body is **escaped by default** — a `<div>`, `<a class="btn">`, or `<main class="hero">` renders as *literal text*, not markup, unless you opt the page in. **To use any raw HTML on a page, add `html: true` to that file's frontmatter** (every page — and every `@include` — carries its own frontmatter, so opt each in). Rule of thumb: **if a page contains a raw HTML tag, it needs `html: true`.**
15
+
16
+ ```wd
17
+ ---
18
+ title: My page
19
+ html: true ← required for the raw <main>/<div>/<a> below to render as HTML
20
+ ---
21
+ ```
22
+
23
+ `{ name }` interpolation, directives, and Markdown never needed `html: true` — only literal HTML tags do. One thing `html: true` does **not** unlock: an inline `<script>` is still blocked by the shipped Content-Security-Policy — put behavior in a colocated `.js` file instead.
11
24
 
12
25
  ## Project layout & routing
13
26
 
@@ -44,7 +57,7 @@ Count: { count }
44
57
  :button "Add item" -> cart += {"id": 1, "name": "Sticker"}
45
58
  :button "Reset" -> count = 0
46
59
  ```
47
- Button actions are a **fixed whitelist**: `x++`, `x--`, `x += <number>`, `list += <json>`, `x = <json>`. Target must be declared `:state`. Literal values are JSON. **`:button` is for state actions only** — for a link styled as a button, write raw HTML `<a class="btn" href="…">`.
60
+ Button actions are a **fixed whitelist**: `x++`, `x--`, `x += <number>`, `list += <json>`, `x = <json>`. Target must be declared `:state`. Literal values are JSON. **`:button` is for state actions only** — for a link styled as a button, write raw HTML `<a class="btn" href="…">` (raw HTML needs `html: true` in the page frontmatter — see the raw-HTML note above; or use a trailing `[label](/url){.btn}` inline-attribute, which needs no `html:` opt-in).
48
61
 
49
62
  **Per-row actions (inside a reactive `@loop … into item`):** two actions reference the current row instead of a JSON literal —
50
63
  ```wd
@@ -255,7 +268,7 @@ Always include: a tokens block, a real type scale, generous spacing, a `max-widt
255
268
 
256
269
  **Scoped styles — opt in with `scoped`.** A `.skin` is **global** by default (style site-wide; the right default for your design system). To stop a reusable component's classes from clashing with the rest of the page, make the **first line** of its `.skin` the word `scoped` — its selectors then only match the component it's colocated with (a page skin scopes that page; an include skin scopes just that include's subtree). You still write `class="card"`; the build stamps a `data-wd-scope` attribute and rewrites the selectors — zero runtime, a static page stays static. **Tokens stay global** (a `tokens`/`tokens dark` block still emits `:root` vars, so `$accent` and dark mode keep working). Escape a single rule with `:global(.x)` (whole-selector only). `page`/`*`/`html`/`body`/`::selection` are a compile error in a scoped skin — those belong in a global skin.
257
270
 
258
- **Layout gotcha — CTAs and link rows.** Markdown wraps adjacent links/buttons on consecutive lines into a single `<p>`, so putting `display:flex` on their container won't space them. To lay out a row of buttons or nav links, wrap them in explicit raw HTML and style that:
271
+ **Layout gotcha — CTAs and link rows.** Markdown wraps adjacent links/buttons on consecutive lines into a single `<p>`, so putting `display:flex` on their container won't space them. To lay out a row of buttons or nav links, wrap them in explicit raw HTML and style that (the page needs `html: true` in its frontmatter for the raw `<div>`/`<a>` to render — see the raw-HTML note above):
259
272
  ```wd
260
273
  <div class="actions">
261
274
  <a class="btn" href="/signup">Start free</a>
@@ -274,7 +287,7 @@ Always include: a tokens block, a real type scale, generous spacing, a `max-widt
274
287
 
275
288
  Directives are narrow on purpose. For real logic (filtering, custom interactions), use a colocated `.js` with `window.wd` (`wd.get`, `wd.set`, `wd.state`, `wd.render`). Section state is keyed `sectionId:name`.
276
289
 
277
- **Canonical "filter a list" pattern** (filtering is not a directive). Keep the fetched/source list intact and filter the rendered DOM — do not overwrite the source state, do not poll with setInterval:
290
+ **Canonical "filter a list" pattern** (filtering is not a directive). Keep the fetched/source list intact and filter the rendered DOM — do not overwrite the source state, do not poll with setInterval (the raw `<input>` needs `html: true` in the page frontmatter, or use `:bind q` which needs no opt-in):
278
291
  ```wd
279
292
  <input id="q" placeholder="Search" autocomplete="off">
280
293
 
@@ -313,6 +326,7 @@ This visual DOM filter is fine for a static fetched list. (Note: a `wd.render()`
313
326
  ```wd
314
327
  ---
315
328
  title: Hello
329
+ html: true ← the raw <main class="hero"> below needs this
316
330
  ---
317
331
 
318
332
  @include /nav.wd
package/CHANGELOG.md CHANGED
@@ -2,6 +2,46 @@
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
+ ## 2.0.1 — 2026-07-11
6
+
7
+ A tightening pass from a post-2.0 product review: a silent reactive-loop failure becomes a loud compile error, scaffolds stop shipping the raw-HTML footgun, and every scaffolded page gets real landmarks back. All compile-time — the runtime is byte-identical (7518 B gzipped).
8
+
9
+ - **A third reactive `@loop` level is now a compile error.** The runtime supports one level of reactive nesting; a third reactive level previously compiled clean and painted the inner region empty at runtime. It now fails the build with `file:line`, the offending opener quoted, and a corrective suggestion — including when the third level arrives through an `@include` (nesting depth threads across the include boundary). Static (build-unrolled) nesting is unaffected at any depth.
10
+ - **`::: nav` and `::: main` containers emit real landmark elements.** A container named `nav` or `main` now compiles to `<nav class="nav">` / `<main class="main">` instead of a `<div>` (the name stays as a class, so existing `.nav`/`.main` skin selectors match unchanged; every other name still emits a `<div>`). With that, the shell reuses the container's `<main>` for its `id="main"` skip-link target instead of wrapping the whole body — nav chrome sits outside `<main>` and "Skip to content" actually skips it.
11
+ - **Scaffold templates no longer set `html: true`.** 2.0.0 made escaped markdown the default and called raw-HTML mode the #1 footgun — while every template scaffolded with the flag on. All five templates now express their nav/layout/CTAs with container directives and `{.class}` inline attributes instead of raw HTML (zero `html: true` anywhere in a fresh scaffold), and with the landmark containers above they keep proper `<nav>`/`<main>` structure. The starter's `about.md` becomes pure CommonMark with a "Back home" link — a `.md` page can't include the shared nav, and the page now says so instead of duplicating raw markup.
12
+ - **`file:line` on the remaining compile errors.** Malformed `@loop` headers and include-resolution failures (unresolvable path, sandbox escape) reported only the file; they now report `file:line` like every other directive error, and the unresolvable-include message gained a hint ("Looked in site/pages and site/_ — check the path and leading slash").
13
+ - **Size prose is drift-guarded.** Docs and site copy quoting the runtime size (now "~7.5 KB" everywhere current-tense) are asserted against `.size-snapshot.json` by a new test, so the number in the prose can never silently diverge from the shipped bytes again.
14
+ - **darkmown.com hero: install one-liner with a copy button.** The landing page now puts `npx @zvndev/darkmown init my-site` (click-to-copy) directly under the CTAs, and the site docs gained a CLI reference section covering all six commands.
15
+ - **Internal: a build-throughput benchmark.** `npm run bench:build` generates a synthetic site (collections, includes, static loops, highlighted code) and reports pages/sec; it refuses to touch a `site/` directory it didn't generate itself.
16
+
17
+ ## 2.0.0 — 2026-07-11
18
+
19
+ The raw-HTML markdown default flips to escaped (`html: false` — the breaking change that makes this a major), alongside security hardening and a DX pass from a product review: incremental dev rebuilds, true error line numbers, a docs table of contents, and styled templates. Nearly all build-time and dev-server; the one runtime change — deterministic UTC date pipes — nudges the runtime to 7518 B gzipped, still well under the 8 KB budget.
20
+
21
+ - **BREAKING: raw HTML in markdown is now escaped by default.** markdown-it runs with `html: false`, so a raw `<script>` or `onerror=` attribute in a `.md`/`.wd` body renders as inert text instead of executing — with 1.5.0 collections making multi-author markdown first-class, passthrough-by-default was a stored-XSS default. **Migration:** add `html: true` to the frontmatter of each page or include that intentionally uses raw inline HTML (every file carries its own frontmatter; the demo site and all five scaffold templates were updated this way). `{ name }` interpolation, fenced/inline code, and highlighting are unaffected.
22
+ - **CSP hardening: `'unsafe-inline'` removed from `script-src` on every page.** The inline state seed is a non-executable JSON data block that `script-src` never gated; the one gated inline script — the fixed `transitions: true` speculationrules block — is now authorized by a build-time `'sha256-…'` hash source (plus `'inline-speculation-rules'`), computed in `src/headers.js` and drift-guarded against the page compiler's real output. Reactive pages keep `'unsafe-eval'` (the runtime's `new Function` needs it; a hash cannot replace it). Applies to `dist/_headers`, `vercel.json`, and the local `serve`/preview server alike. A raw inline `<script>` on an `html: true` page is now blocked by the shipped CSP — use a colocated `.js` file (allowed by `'self'`) or widen `script-src` deliberately. `img-src`/`media-src` keep their `https:`-wide default; SECURITY.md documents the tightening path for self-hosted-assets sites.
23
+ - **Build-output containment guard.** `outputPathForRoute` now throws if a route would resolve its `index.html` outside the build output directory — defense in depth behind the route-discovery walker, which cannot produce traversal segments today.
24
+ - **`lang:` frontmatter.** A page sets its document language with `lang: fr` (any BCP-47 tag) and the shell emits `<html lang="fr">` (escaped); the default stays `en`.
25
+ - **Skip-to-content link + guaranteed main landmark.** Every page's first focusable element is a visually-hidden-until-focused "Skip to content" link. The shell reuses your `<main>` (an author id wins as the skip target; an id-less `<main>` gets `id="main"` stamped on — a `data-id` or similar `-id`-suffixed attribute is never mistaken for the real id) or wraps a main-less body in `<main id="main">`. Compile-time only — static pages stay `runtime: false`. Restyle via `.wd-skip-link`; the site base skin ships a themed focus reveal.
26
+ - **`:fetch` lifecycle regions are announced.** A bare `:if name_loading` over a fetch key compiles to a `role="status" aria-live="polite"` region and `:if name_error` to `role="alert"`, so the runtime's existing show/hide flips are read by assistive tech with zero added runtime bytes. Author-supplied `role`/`aria-live` inside a region is never overridden.
27
+ - **Heading anchors + a docs table of contents.** Every markdown heading now carries a stable, GitHub-style slug `id` at build time (lowercased, punctuation stripped, whitespace → hyphens; duplicates dedupe with `-1`/`-2` suffixes across the whole document, even when directives split the prose into chunks or a `.md` include repeats a heading from the including page), so any section of any page is deep-linkable with a plain `#` fragment and zero JS. The docs page opens with an "On this page" TOC built from those anchors — static markdown links, so `/docs/` stays `runtime: false` — and a dogfood test verifies every TOC fragment against the built page's real ids. The docs Install section also points at the VS Code extension's install-from-VSIX flow.
28
+ - **Fixed: highlighted pages emitted a mangled stylesheet link.** The `/__wd/highlight.css` `<link>` was wrapped inside a second link tag's `href` attribute on every page with a highlighted code block. Each such page now emits exactly one well-formed tag, still ordered before page skins so `$code-*` overrides cascade.
29
+ - **`@loop` over a missing field is an empty list, not an error.** An in-scope loop source that resolves to `undefined`/`null` — like an optional frontmatter field (`tags: string[]?`) a page omits — loops zero rows and renders the `@empty` branch, matching the runtime. Previously `build --drafts` died on a draft post without `tags`. A present-but-non-list value is still a compile error, now with the true `file:line` and a corrective hint.
30
+ - **Compile errors report true file line numbers.** Line numbers were computed over the frontmatter-stripped body, so an error after a 3-line frontmatter block pointed three lines short. The frontmatter offset is now threaded through the compile context, so `file:line` matches what an editor's jump-to-line expects. The same offset also threads through every **nested block body** — a directive inside a `::: container`, `@loop` rows or `@empty` branch, `:if`/`:else if`/`:else` branch, `:form`, or `:carousel` previously reported a line relative to the sliced block body (wrong but plausible); it now reports the true file line at any nesting depth.
31
+ - **Incremental dev rebuilds.** `darkmown dev` now tracks a per-route dependency map (source file, includes, colocated `.skin`/`.js` assets, `@loop` JSON data, collections looped — written to `dist/.wd-dev-deps.json` on every dev build) and rebuilds only the routes affected by a `site/` content change: editing an include recompiles just the pages that include it, editing a collection entry recompiles the entry plus the collection's listing/paginated pages, and `routes.json`/`_headers`/sitemap/rss are re-emitted globally every time so partial rebuilds stay whole-site consistent. Any uncertainty — a new/deleted/renamed file, a file the map doesn't account for, a change to the site-wide feed link — falls back to a full rebuild, and a framework `src/` change still always runs the full child-process rebuild. Rebuild child processes are **serialized** — at most one runs at a time, with changes arriving mid-build coalescing into the next batch — because every build read-modify-writes the dependency map, and overlapping children could persist a stale map that silently stops affected routes from rebuilding. The dev log says what happened: `Rebuilt 2 of 29 routes (/blog/, /blog/hello/) into dist`. Production `darkmown build` is unchanged and writes no map.
32
+ - **The dev server no longer lies about a failed initial build.** The full compile error prints prominently, the "ready" line states the build failed (with the fix-to-retry flow), and unbuilt HTML routes serve a build-failure page carrying the dev client — the SSE overlay replays the error and the next successful build reloads — instead of the misleading "hidden or has not been created" 404.
33
+ - **Dogfood regression tests.** The repo's own site now builds with `--drafts` in tests and CI (the staging path a default build never exercises), and every built page is swept for tag markup inside attribute values (the double-wrapped-link class of bug).
34
+ - **Blog template runs on content collections.** `darkmown init --template blog` no longer ships a hand-maintained `site/_/posts.json` manifest (whose dates contradicted the posts' frontmatter): `site/pages/posts/` is the collection, the home page is one `@loop posts into post sort by post.date desc` with an `@empty` branch, and a `_schema.wd` type-checks every post's frontmatter at build time. Adding a post is now just adding a `.md` file. Also fixes the listing's date/excerpt, which the old raw-HTML card markup rendered as literal `{ post.date }` text.
35
+ - **Scaffolded `.md` pages are styled.** The starter's `about.md` and the blog template's posts now ship colocated `.skin` siblings (the same colocation that styles a `.wd` page — no new mechanism) plus visible navigation back home, so a fresh template has no unstyled dead-end pages. The pages stay `runtime: false` with zero scripts, guarded by a template test.
36
+ - **The repo's own demo blog posts are styled.** `site/pages/blog/*.md` (the posts on darkmown.com) were rendering as bare, unstyled HTML because a plain `.md` post can't `@include /base.wd`. Each post now carries a colocated `.skin` reading layer that mirrors the site's base tokens (serif prose, warm-literary palette, OS-driven dark mode) — closing the same gap the scaffold templates just fixed. The posts stay `runtime: false` with zero scripts, guarded by a new dogfood test asserting every built blog post links a stylesheet.
37
+ - **Fixed: the `:fetch` e2e harness now boots the runtime under the shipped CSP.** With the reactive CSP dropping `'unsafe-inline'` from `script-src`, the fetch spec's self-contained harness — which injected the runtime as an inline `<script>` — was blocked. It now injects the runtime by URL (`/__wd/runtime.js`, same-origin, covered by `script-src 'self'`), matching how the other e2e specs load it and exercising the real shipped runtime. Product code and the CSP are unchanged; only the test harness was.
38
+ - **VS Code extension is package-ready.** `npm run pack:extension` builds a clean `editors/vscode/darkmown-<version>.vsix` (git-ignored); the README's Editor support section now documents the install-from-VSIX flow, and `editors/vscode/PUBLISHING.md` records the Marketplace publisher/PAT prerequisites and exact publish commands — the Marketplace listing itself is pending.
39
+ - **Fixed: date pipes are now deterministic across build machines.** A date-only value like `{ "2026-06-22" | date:"medium" }` parses as UTC midnight, so formatting it in the build machine's local zone could render `Jun 21, 2026` west of UTC. Date-only strings (`YYYY-MM-DD`) now format with `timeZone: 'UTC'` in both the compiler fold and the runtime mirror, so the output matches the written calendar date everywhere; full datetimes keep their local-zone behavior. This is the one runtime change in 2.0.0 (7459 → 7518 B gzipped, still well under the 8 KB budget); `tests/format-parity.test.js` keeps the compiler and runtime formatters in lockstep.
40
+ - **Fixed: chained `:fetch` lifecycle regions keep their live-region roles.** In the documented four-state pattern (`:if x_loading` / `:else if x_error` / `:else`), the loading region's compiled falsy branch contains the nested error region's framework-added `role="alert"` — which the author-aria guard mistook for author-supplied aria and used to suppress the loading region's own `role="status" aria-live="polite"`. The guard now inspects only the region's own source, so a genuine author `role`/`aria-live` still wins but a nested framework attribute no longer silences the outer region.
41
+ - **`darkmown serve` now applies the per-route CSP, matching production.** The local preview server previously served the relaxed (reactive) CSP to *every* route, so a static page got `script-src 'unsafe-eval'` locally that its production `_headers`/`vercel.json` would not grant. It now reads the build's `routes.json` and serves each route the same strict (static) or relaxed (reactive) policy production ships; an unknown route or missing manifest falls back to the reactive policy, exactly like the production catch-all. `darkmown dev` is unchanged.
42
+ - **Clearer feed build hints.** `darkmown build` now warns when `site_url` is still the `https://example.com` placeholder (the blog template's default) so feeds/sitemap don't silently point at example.com, and the existing "set `site_url`" hint prints once per `darkmown dev` session instead of on every incremental rebuild.
43
+ - **Internal: the directive-handler module was split along its cohesive seams** (`state`/`actions`/`fetch`/`forms`/`media`/`structure` under `src/compiler/`, with `directives.js` now a re-export barrel), and the compiler's import cycles were removed — block handlers and the loop pipeline recurse through `ctx.compileBody`/`ctx.compileFile` instead of importing the dispatcher back, and `stampScope` moved beside the scoped-skin detection in `includes.js`. Pure refactor: no public API, output, or behavior change.
44
+
5
45
  ## 1.5.0 — 2026-06-28
6
46
 
7
47
  Content collections — query a folder of markdown as data, with optional typed validation and pagination. Plus a default-styling pass so buttons and form controls look good out of the box, and two fixes from a product review. The runtime is unchanged (still 7459 B gzipped); collections and styling are entirely build-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 ~7.4 KB gzipped, CI-enforced under 8 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 ~7.5 KB gzipped, CI-enforced under 8 KB.
11
11
 
12
12
  ## Showcase
13
13
 
@@ -51,7 +51,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
51
51
  ## Commands
52
52
 
53
53
  - `darkmown init [dir] [--template <name>]` scaffolds a new site from a template (`starter`, `blog`, `store`, `dashboard`, `landing`).
54
- - `darkmown dev` starts the live compiler with browser reload, an in-browser error overlay, and a local runner for `api/*` functions.
54
+ - `darkmown dev` starts the live compiler with browser reload, an in-browser error overlay, and a local runner for `api/*` functions. Rebuilds are incremental: each dev build records a per-route dependency map (page file, includes, colocated assets, loop data, collections), so editing one file recompiles only the routes that depend on it — with a full rebuild on any uncertainty (a new/deleted/renamed file, or a framework `src/` change). Rebuilds run one at a time; edits landing mid-build batch into the next one.
55
55
  - `darkmown build [--target cloudflare] [--drafts]` writes static output to `dist` (plus `sitemap.xml`/`rss.xml`/`robots.txt`); `--drafts` includes `draft: true` pages for staging.
56
56
  - `darkmown deploy <vercel|cloudflare> [--prod]` builds and deploys via the platform CLI.
57
57
  - `darkmown serve` previews the built `dist` locally.
@@ -65,7 +65,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
65
65
  - Files or folders starting with `.`, `-`, or `_` are hidden from routing.
66
66
  - `site/_` is the include shelf for `@include /name.wd`.
67
67
  - Matching `page.skin` and `page.js` colocate styling and behavior by basename.
68
- - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~7.4 KB gzipped, CI-enforced under 8 KB).
68
+ - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~7.5 KB gzipped, CI-enforced under 8 KB).
69
69
  - Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
70
70
 
71
71
  ## Interpolation
@@ -82,17 +82,21 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
82
82
 
83
83
  Shape a value for display with a pipe, Liquid/Angular style: `{ price | money }`, `{ joinedAt | date:"medium" }`, `{ ratio | percent }`. Pipes **chain** left to right (`{ name | trim | capitalize }`) and take literal arguments (`{ total | money:"EUR" }`, `{ bio | truncate:80 }`). They work in static prose (folded at build time, zero-JS) and on live `:state`/`:store` bindings (re-applied on every render) — identical syntax either way. The pipe list is a fixed whitelist compiled to safe calls; there is no custom-function hook and nothing is `eval`'d.
84
84
 
85
+ A pipe shapes a value that is **in scope** — a loop variable, `:state`/`:store`, an include argument, or `meta`. The examples below read a field off a loop row `p` (e.g. inside `@loop products into p`, with `p.price == 89`, `p.joined == "2026-06-22"`, …). A bare literal like `{ 89 | money }` is **not** a name in scope, so — like any unresolved `{ … }` — it is left as literal text, braces and all; pipes only transform a value the page can resolve.
86
+
85
87
  | Pipe | Example | Result |
86
88
  |---|---|---|
87
- | `money[:currency[:locale]]` | `{ 89 \| money }` | `$89.00` |
88
- | `number[:decimals]` | `{ 1234.5 \| number }` | `1,234.5` |
89
- | `percent[:decimals]` | `{ 0.082 \| percent }` | `8%` |
90
- | `round[:decimals]` | `{ 3.14159 \| round:2 }` | `3.14` |
91
- | `date` / `time` / `datetime` `[:style]` | `{ "2026-06-22" \| date:"medium" }` | `Jun 22, 2026` |
92
- | `upper` / `lower` / `capitalize` | `{ "draft" \| capitalize }` | `Draft` |
93
- | `truncate:n` / `trim` | `{ bio \| truncate:80 }` | `…the first 80 chars…` |
94
- | `pluralize:"item"[:"plural"]` | `{ 3 \| pluralize:"item" }` | `3 items` |
95
- | `default:"—"` | `{ missing \| default:"—" }` | `—` |
89
+ | `money[:currency[:locale]]` | `{ p.price \| money }` | `$89.00` |
90
+ | `number[:decimals]` | `{ p.units \| number }` | `1,234.5` |
91
+ | `percent[:decimals]` | `{ p.ratio \| percent }` | `8%` |
92
+ | `round[:decimals]` | `{ p.pi \| round:2 }` | `3.14` |
93
+ | `date` / `time` / `datetime` `[:style]` | `{ p.joined \| date:"medium" }` | `Jun 22, 2026` |
94
+ | `upper` / `lower` / `capitalize` | `{ p.status \| capitalize }` | `Draft` |
95
+ | `truncate:n` / `trim` | `{ p.bio \| truncate:80 }` | `…the first 80 chars…` |
96
+ | `pluralize:"item"[:"plural"]` | `{ p.qty \| pluralize:"item" }` | `3 items` |
97
+ | `default:"—"` | `{ p.nickname \| default:"—" }` | `—` |
98
+
99
+ (A date-only value like `p.joined == "2026-06-22"` formats in UTC, so `date:"medium"` renders the written calendar date on any build machine.)
96
100
 
97
101
  The five **aggregates** double as pipes over a list — `{ cart \| count }`, `{ cart \| sum:"price" \| money }`, plus `avg` / `min` / `max` — and `join:", ":"name"` flattens a list of rows to a string. `date`/`time`/`datetime` are `Intl`-backed (`short` / `medium` / `long`); there is deliberately **no relative "time ago"** formatter, so builds stay byte-for-byte reproducible. An unknown pipe name is a compile error with the valid list.
98
102
 
@@ -111,7 +115,7 @@ tags: [sales, revenue, "q1, q2"]
111
115
  - `@loop meta.tags into tag` iterates an array field at build time (stays static, zero-JS).
112
116
  - Arrays are inline flow only (`[a, b]`); quoted items keep internal commas (`"q1, q2"`). A value without a leading `[` stays a plain string.
113
117
 
114
- 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).
118
+ 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). `lang:` sets the document language on `<html lang>` (`lang: fr`, `lang: pt-BR`, …) — it defaults to `en`.
115
119
 
116
120
  A few reserved keys drive **drafts and feeds** (see [SEO & feeds](#seo--feeds-sitemap-rss-robots) below): `draft: true` excludes a page from production builds; `site_url` on the home page turns on `sitemap.xml` + `rss.xml`; `date:` marks a page as a blog post (it lands in `rss.xml` and sets the page's `<lastmod>`); `excerpt:` is the RSS summary. The same frontmatter is queryable when the page is an entry in a [content collection](#content-collections) — `{ post.date }`, `{ post.excerpt }`, `{ post.tags }`, and any custom key resolve in an `@loop` over the folder.
117
121
 
@@ -293,6 +297,8 @@ Nothing left to do.
293
297
  @endloop
294
298
  ```
295
299
 
300
+ A **missing** in-scope source is an empty list, not an error: `@loop meta.tags into tag` over a page whose frontmatter omits `tags` (say, an optional `tags: string[]?` schema field) loops zero rows and renders the `@empty` branch. A field that is *present but not a list* (a string, a number) is still a compile error with the file:line.
301
+
296
302
  > **Note:** All of these clauses stay build-time when the source and every clause argument are static — a sorted, limited loop over a JSON file ships **zero JavaScript**. The loop becomes reactive only when the source is `:state`/`:store`/`:fetch` data, or a clause reads reactive state (like `limit pageSize`).
297
303
 
298
304
  ### Editable lists — per-row actions
@@ -347,7 +353,7 @@ That is a full add-to-cart / remove-line flow — and a to-do list with delete
347
353
  - Interpolation inside the inner body resolves the inner item first, then the outer item — `{ line.qty }` and `{ order.ref }` are both in scope.
348
354
  - 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.
349
355
 
350
- > **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.
356
+ > **Honest caveat:** nesting is **one level only** — an inner reactive loop that itself contains a third reactive loop is a **compile error** with a corrective message, not a silent runtime failure. Build-time loop nesting is unaffected by this limit.
351
357
  >
352
358
  > **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.
353
359
 
@@ -434,6 +440,8 @@ Cart has { count } items.
434
440
 
435
441
  State declared inside a section is scoped to it — two sections can both own a `count`. Bindings and actions resolve to the nearest scope.
436
442
 
443
+ A container named `nav` or `main` emits the real landmark element (`<nav class="nav">`, `<main class="main">`) instead of a `<div>`, so scaffolded pages keep proper landmarks — a skip link skips a `::: nav`, and `::: main` becomes the page's `<main id="main">`. Any other name stays a `<div>` with that class.
444
+
437
445
  ### Reactive classes — `.class when <predicate>`
438
446
 
439
447
  A container class can be toggled by a predicate. Static `.class` tokens are unchanged; add `when <predicate>` to make one reactive:
@@ -600,6 +608,8 @@ No team members yet.
600
608
  :endif
601
609
  ```
602
610
 
611
+ The lifecycle regions announce themselves: a bare `:if name_loading` compiles with `role="status" aria-live="polite"` and `:if name_error` with `role="alert"`, so assistive tech hears the flips with no extra markup. Author-supplied `role`/`aria-live` inside a region always wins.
612
+
603
613
  ### Options
604
614
 
605
615
  - `method=` — `GET` (default), `POST`, `PUT`, `PATCH`, or `DELETE`.
@@ -757,7 +767,7 @@ export default async function (request, context) {
757
767
  - **Deploy in one command.** `darkmown deploy vercel` (functions run natively) or `darkmown deploy cloudflare` (the build emits a `dist/_worker.js` that routes `/api/*` and serves the rest from `env.ASSETS`). It prints your URL, or the login to run if the platform CLI isn't signed in.
758
768
  - **Custom server / remote backend.** Point `:fetch`/`:form` at an absolute `https://…` URL and widen the CSP `connect-src` (and `form-action` for native form POSTs). Darkmown owns no server — it adapts to yours.
759
769
 
760
- Templates get you to a running, deployable app fast: `darkmown init shop --template store` ships a cart **and** an `api/checkout.js`; `--template dashboard` ships a `:fetch` view **and** an `api/metrics.js`.
770
+ Templates get you to a running, deployable app fast: `darkmown init shop --template store` ships a cart **and** an `api/checkout.js`; `--template dashboard` ships a `:fetch` view **and** an `api/metrics.js`; `--template blog` ships a typed posts collection (`_schema.wd` + one `@loop` over the folder) — adding a post is adding a `.md` file.
761
771
 
762
772
  ## Interactions — `:slider`, `:sortable`, `:carousel`
763
773
 
@@ -797,6 +807,8 @@ A trailing `{.class .class #id}` attaches classes / an id to the inline element
797
807
 
798
808
  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 `#`.
799
809
 
810
+ **Headings get anchors for free.** Every markdown heading carries a stable, GitHub-style slug `id` at build time (lowercased, punctuation stripped, whitespace → hyphens; duplicates dedupe with `-1`/`-2` suffixes across the whole document), so any section of any page is deep-linkable with a plain `#the-slug` fragment — zero JS. The [docs page's](https://darkmown.com/docs/) "On this page" table of contents is just markdown links to those anchors.
811
+
800
812
  ## Media — `:video`, `:audio`, `:embed`
801
813
 
802
814
  *New in 1.0.* Three one-line directives replace hand-written `<video>` / `<iframe>` markup. They are **compile-time only** — they emit no `data-wd-*`, so a media-only page still ships **zero** framework JavaScript.
@@ -952,7 +964,25 @@ Set `window.wd.debug = true` (it defaults to `false`) to log any `:computed` or
952
964
 
953
965
  ## Editor support
954
966
 
955
- A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown. For launch, it is source-installable: build a `.vsix` with `npm run pack:extension`, then install `editors/vscode/darkmown-*.vsix` with VS Code. Marketplace/Open VSX publishing is tracked as post-launch distribution work, so the public package does not imply store availability yet.
967
+ A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown.
968
+
969
+ Install from a `.vsix` today:
970
+
971
+ ```sh
972
+ npm run pack:extension # builds editors/vscode/darkmown-<version>.vsix
973
+ code --install-extension editors/vscode/darkmown-*.vsix
974
+ ```
975
+
976
+ Or inside VS Code: Extensions panel → `…` menu → **Install from VSIX…** and pick the built file. A Visual Studio Marketplace listing is pending (see [`editors/vscode/PUBLISHING.md`](editors/vscode/PUBLISHING.md)); until it lands, the `.vsix` route above is the supported install.
977
+
978
+ ## Accessibility
979
+
980
+ Every compiled page ships with landmark-and-announcement basics baked in at build time — zero runtime JS, so static pages stay static:
981
+
982
+ - **Skip link.** The first focusable element on every page is a visually-hidden-until-focused "Skip to content" link. The shell guarantees it has a target: your own `<main>` is reused (an existing `id` wins as the skip target; an id-less `<main>` gets `id="main"` stamped on), and a page without one is wrapped in `<main id="main">`. Restyle it via `.wd-skip-link` in a skin.
983
+ - **Document language.** `lang:` frontmatter sets `<html lang>` per page (default `en`).
984
+ - **Live `:fetch` regions.** A bare `:if name_loading` region over a `:fetch` key compiles to `role="status" aria-live="polite"` and `:if name_error` to `role="alert"`, so screen readers announce loading and error flips for free. Write your own `role`/`aria-live` inside the region and Darkmown adds nothing.
985
+ - **Accessible names on form controls.** Generated `:input`/`:bind`/`:textarea`/`:select` controls without an author-supplied `aria-label`/`aria-describedby` get an `aria-label` derived from the placeholder or field name; `:slider` and choice groups do the same.
956
986
 
957
987
  ## Security
958
988
 
@@ -961,10 +991,10 @@ A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin
961
991
  Darkmown is a **trusted-author** site generator: you compile content you wrote, the same way you trust your own source code. Three assumptions hold the model together — know them before you point Darkmown at content from anyone else.
962
992
 
963
993
  - **Compile only trusted, author-written content.** Do not compile `.md`/`.wd` files you did not write — user-generated content, third-party docs, form input — without sanitizing it first.
964
- - **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.
994
+ - **Raw HTML is escaped by default; `html: true` opts a page back in.** Darkmown runs `markdown-it` with `html: false`, so a raw `<script>` or event-handler attribute in content renders as inert escaped text instead of executing — multi-author content (blog collections, contributed docs) is stored-XSS-safe out of the box. A page whose author writes their own HTML sets `html: true` in its frontmatter to pass raw HTML through verbatim. There is still no built-in sanitizer: on an `html: true` page, untrusted content executes in the visitor's browser.
965
995
  - **`: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).
966
996
 
967
- > **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)).
997
+ > **The single biggest footgun:** do not put `html: true` on a page whose content is even partly out of your hands, and do not compile untrusted or user-submitted Markdown without sanitizing it first — there is no built-in sanitizer (see [SECURITY.md](SECURITY.md)).
968
998
 
969
999
  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.
970
1000
 
@@ -972,7 +1002,7 @@ Directive actions and `:computed`/`@loop … where` expressions are never `eval`
972
1002
 
973
1003
  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:
974
1004
 
975
- - **`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.
1005
+ - **`Content-Security-Policy`** — no `'unsafe-inline'` on `script-src` for any page: the only inline script the framework emits that CSP gates (the `transitions: true` speculationrules block) is authorized by a build-time `'sha256-…'` hash, and the inline state seed is a non-executable JSON data block CSP does not gate. Static pages get a tight, eval-free policy; **reactive pages** (those that ship `/__wd/runtime.js`) add `script-src 'unsafe-eval'`, which the runtime needs to compile validated `:computed` / `@loop … where` / `.class when` expressions via `new Function`. A raw inline `<script>` you write into an `html: true` page is blocked by the shipped CSP — put it in a colocated `.js` file (same-origin, allowed by `'self'`) or widen `script-src` deliberately.
976
1006
  - **`X-Content-Type-Options: nosniff`**, **`Referrer-Policy`**, and a **`frame-ancestors`** directive (clickjacking protection) on every page.
977
1007
 
978
1008
  **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.
package/SECURITY.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Supported versions
4
4
 
5
- Darkmown is pre-1.0. Only the latest published minor version receives security fixes.
5
+ Only the latest published minor version receives security fixes.
6
6
 
7
7
  ## Reporting a vulnerability
8
8
 
@@ -15,10 +15,10 @@ You can expect an initial response within 72 hours. If the report is accepted, a
15
15
  Darkmown is a **trusted-author** static site generator. The threat model assumes you compile content you wrote yourself, the same way you trust your own source code — it does not assume the content is hostile. Within that boundary the guarantees below hold; three assumptions define the boundary itself:
16
16
 
17
17
  1. **Compile only trusted, author-written content.** Do not compile `.md`/`.wd` files you did not write — user-generated content, third-party docs, or form input — without applying your own sanitization first.
18
- 2. **Raw HTML passes through by default.** `markdown-it` runs with `html: true`, so raw HTML in content is emitted verbatim. There is no built-in sanitizer, so 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 strips raw HTML.
18
+ 2. **Raw HTML is escaped by default; `html: true` re-opens the boundary per page.** `markdown-it` runs with `html: false`, so raw HTML in content renders as inert escaped text — a `<script>` or `onerror=` attribute in a contributed markdown file cannot execute. A page opts back into verbatim raw HTML with `html: true` frontmatter; on such a page there is no built-in sanitizer and untrusted content would execute in the visitor's browser, so reserve the opt-in for pages whose HTML you wrote yourself.
19
19
  3. **`:fetch` and `:form action=` have no host allowlist, and reactive pages require `unsafe-eval`.** A `:fetch`, `refresh=`, or `:form action=` URL is read directly from the page source; the compiler rejects non-http(s) schemes (`file:`, `data:`, `javascript:`, protocol-relative `//host`) but does not restrict which hosts you call, so SSRF/exfiltration protection is the author's responsibility. Reactive pages also need `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions through `new Function`; a strict CSP without `unsafe-eval` breaks reactive pages (static pages are unaffected).
20
20
 
21
- This is the same trust posture as every Markdown SSG: the framework is safe for the trusted-author use case it targets, and unsafe content is the author's responsibility to sanitize before it reaches the compiler.
21
+ The default is deliberately stricter than most Markdown SSGs: with content collections making multi-author markdown a first-class input, escaped-by-default is the only default that is safe when one of those authors isn't you. Unsafe content on an `html: true` page remains the author's responsibility to sanitize before it reaches the compiler.
22
22
 
23
23
  ## Security model
24
24
 
@@ -31,45 +31,48 @@ 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 #1 footgun: raw HTML passthrough (`html: true`)
34
+ ## The #1 footgun: the raw HTML opt-in (`html: true`)
35
35
 
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.**
36
+ This is the single most important thing to understand about Darkmown's security model. **Read this before putting `html: true` on a page.**
37
37
 
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.
38
+ Since 2.0.0, Darkmown configures markdown-it with `html: false` by default: **raw HTML in `.md`/`.wd` files is escaped**, so a `<script>` or an `onerror=` attribute in content renders as visible inert text instead of executing. This makes multi-author content — blog collections, contributed docs, anything you merge from a PR — stored-XSS-safe without any per-page setting.
39
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**.
42
- - If you must render untrusted content, sanitize it (for example with a library like DOMPurify) before it reaches the compiler.
40
+ ### The `html: true` per-page opt-in
43
41
 
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.
42
+ A page whose author writes their own HTML opts back into verbatim passthrough:
47
43
 
48
44
  ```wd
49
45
  ---
50
- title: User comments
51
- html: false
46
+ title: Landing page
47
+ html: true
52
48
  ---
53
49
  ```
54
50
 
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.
51
+ On an `html: true` page the pre-2.0.0 rules apply in full:
52
+
53
+ - **Treat that page's content as trusted input**, the same way you trust your own source code.
54
+ - **Never compile untrusted or user-submitted Markdown** (comments, form input, third-party docs, scraped content) into an `html: true` page without sanitizing it first. Darkmown ships **no built-in sanitizer**.
55
+ - If you must render untrusted content, sanitize it (for example with a library like DOMPurify) before it reaches the compiler — or simply leave the page on the default strict renderer.
56
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.
57
+ > **Note:** `html:` is a *per-page* (and per-include — every `.wd`/`.md` file carries its own frontmatter) key — there is no global/site-wide toggle today (no config loader exists yet). The safe default means that's the right shape: opt individual hand-written pages in, never a whole site of contributed content.
58
58
 
59
59
  ## Deploying with a Content-Security-Policy
60
60
 
61
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
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.
63
+ - **`Content-Security-Policy`** — **no `'unsafe-inline'` on `script-src`, for any page.** The inline state seed is a `<script type="application/json">` data block, which is non-executable and therefore not gated by `script-src` at all; the one inline script CSP does gate — the fixed `<script type="speculationrules">` block a `transitions: true` page emits — is authorized by a build-time `'sha256-…'` hash source (plus the `'inline-speculation-rules'` keyword for browsers that check that instead). **Static pages** (no runtime) get a tight, eval-free policy. **Reactive pages** (those that ship `/__wd/runtime.js`) additionally need `script-src 'unsafe-eval'`, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions via `new Function` — `'unsafe-eval'` gates the eval-family APIs, not inline script elements, so it cannot be replaced by a hash. A strict CSP without `unsafe-eval` breaks reactive pages; static pages are unaffected and stay strict.
64
64
  - **`X-Content-Type-Options: nosniff`** — stops MIME-type sniffing.
65
65
  - **`Referrer-Policy`** — limits referrer leakage.
66
66
  - **`frame-ancestors`** — clickjacking protection (controls who may frame the page).
67
67
 
68
+ A consequence of dropping `'unsafe-inline'`: a raw inline `<script>` you write into an `html: true` page is **blocked by the shipped CSP**. Put page behavior in a colocated `.js` file instead — it is served same-origin and allowed by `script-src 'self'` — or widen `script-src` in your deploy config as a deliberate decision. (`style-src` keeps `'unsafe-inline'` for the view-transition inline `<style>`.)
69
+
68
70
  ### Tightening the policy
69
71
 
70
72
  The shipped CSP is a sensible default, not a finished policy for every site. Tighten or widen it for your deployment:
71
73
 
72
74
  - **`: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`.
75
+ - **`img-src` / `media-src` default to any `https:` host** — remote images and media in markdown are legitimate on most sites, so the default keeps them working. If your site only serves its own assets, tighten both in your deploy config (`vercel.json`, `dist/_headers`, or your server): `img-src 'self' data:` (keep `data:` — the default favicon is a `data:` SVG) and `media-src 'self'`. If you hotlink from known hosts, list them instead: `img-src 'self' data: https://images.example.com`.
76
+ - **Remote fonts or extra embed hosts** need their hosts added to `font-src` / `frame-src` (the default `frame-src` pre-authorizes exactly the YouTube no-cookie and Vimeo player origins `:embed` rewrites to).
74
77
  - **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
78
  - 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/cli.md CHANGED
@@ -25,7 +25,7 @@ Prints the installed package version.
25
25
  Scaffolds a Darkmown site from a template (default: `starter`). Available templates:
26
26
 
27
27
  - **`starter`** — the minimal counter + todo-loop site.
28
- - **`blog`** — markdown posts + a `.wd` index that loops a JSON manifest.
28
+ - **`blog`** — markdown posts as a typed content collection: `site/pages/posts/` holds the `.md` posts, a `_schema.wd` type-checks each one's frontmatter at build time, and the `.wd` index is one `@loop posts into post sort by post.date desc` over the folder. Adding a post is dropping a `.md` file — no manifest to maintain.
29
29
  - **`store`** — product grid, a shared `:store` cart, and a checkout that posts to `api/checkout.js`.
30
30
  - **`dashboard`** — stat cards that `:fetch` from `api/metrics.js`.
31
31
  - **`landing`** — a marketing one-pager with a `:carousel`.
@@ -55,6 +55,10 @@ Runs a live compiler:
55
55
  - injects `/__wd/dev-client.js`
56
56
  - reloads the browser through `/__wd/dev-events`
57
57
 
58
+ Rebuilds are **incremental** for `site/` content changes. Every dev build writes a per-route dependency map to `dist/.wd-dev-deps.json` — the route's source file, every resolved `@include`, colocated `.skin`/`.js` assets, `@loop` JSON data files, and the collections it loops. A change then rebuilds only the routes whose dependency graph contains the changed file (a collection entry or its `_schema.wd` also rebuilds the collection's listing and paginated pages), and `routes.json`, `_headers`, and the sitemap/rss feeds are re-emitted globally every time so partial rebuilds stay whole-site consistent. The log reports what happened: `Rebuilt 2 of 29 routes (/blog/, /blog/hello/) into dist`.
59
+
60
+ Correctness beats speed: **any uncertainty runs a full rebuild** — a new, deleted, or renamed file; a file no dependency graph accounts for; a change to the site-wide feed link; a missing or stale map. A change under `src/` (the framework's own code) always runs a full rebuild in a child process so fresh modules load. Production `darkmown build` writes no map and is unaffected.
61
+
58
62
  ### `darkmown build [--target cloudflare]`
59
63
 
60
64
  Compiles `site/pages` into `dist`. The output is always 100% static HTML.
@@ -6,10 +6,11 @@ This audit compares the current Darkmown implementation with the original MARROW
6
6
 
7
7
  - **Markdown-first authoring:** `.md` is strict CommonMark rendered by a real parser (markdown-it); `.wd` is the same Markdown plus first-party directives. Renaming the file is the upgrade path — extensions genuinely gate functionality.
8
8
  - **Content tree over components:** routing, includes, and repeated fragments are file/document based instead of React-style components.
9
- - **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~7.4 KB gzipped, CI-enforced below the 8 KB target).
9
+ - **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~7.5 KB gzipped, CI-enforced below the 8 KB target).
10
10
  - **No virtual DOM:** state changes patch direct DOM bindings, conditional regions, and keyed loop regions.
11
11
  - **Keyed list patching:** reactive loops reconcile by item key (`id`/`key`/value) instead of re-rendering the region.
12
12
  - **Sections with scoped state:** `::: section #id .class` containers scope `:state`; bindings and actions resolve through the nearest scope chain.
13
+ - **Semantic landmark containers:** `::: nav` / `::: main` emit the real `<nav>`/`<main>` element (keeping the name as a class for styling); other container names stay `<div>`s. Keeps skip links and landmarks intact without raw HTML.
13
14
  - **`.skin` language:** indentation-based token styling compiles to CSS.
14
15
  - **Folder router:** `site/pages` maps to routes, and `.`, `-`, `_` prefixes hide pages.
15
16
  - **Colocation:** matching `.skin` and `.js` files attach to the page by basename, for includes too.
@@ -89,7 +90,7 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
89
90
  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
 
91
92
  - **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
+ - **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`. *(Since 2.0.0 `'unsafe-inline'` is dropped from `script-src` entirely — see the 2.0.0 entry; `'unsafe-eval'` remains on reactive pages.)* **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). *(Superseded in 2.0.0: strict rendering is now the default and `html: true` is the per-page opt-in — see the 2.0.0 entry.)*
93
94
  - **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
95
  - **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
96
 
@@ -134,7 +135,7 @@ Four progressive-disclosure capabilities plus an assets and a security change. T
134
135
  - **Page-colocated static assets:** any non-page file under `site/pages/` (anything but `.md`/`.wd` routes and colocated `.skin`/`.js`) is copied to `dist/` at its path (`site/pages/logo.svg` → `dist/logo.svg`). The shelf (`site/_/` → `/__wd/media`, `/__wd/data`) is unchanged; a name clash with an emitted route is skipped with a warning. As of v0.14.1, hidden path segments (`.`, `-`, `_`) and symlinked page assets are skipped. `.avif` was added to the served content-types.
135
136
  - **`:fetch` URL scheme hardening (security):** `:fetch`/`refresh=` URLs are validated at compile time — relative, `http(s)://`, or a leading `{ state }` interpolation are allowed; protocol-relative `//host` and non-http(s) schemes (`file:`, `data:`, `javascript:`, …) are rejected with a corrective hint. **This is scheme validation, not a host allowlist** — SSRF to arbitrary http(s) hosts remains the author's responsibility (see the trust boundary).
136
137
 
137
- ## 1.5.0 additions — typed content collections + pagination (2026-06-29)
138
+ ## 1.5.0 additions — typed content collections + pagination (2026-06-28)
138
139
 
139
140
  The 1.5.0 cycle ships the headline **content collections** feature: turning a folder of pages into a queryable, typed, paginated data source — entirely at build time. **Zero runtime impact** — it is build-time glue over the existing `@loop` pipeline; `src/runtime.js` and the 8 KB budget are untouched, and a pure collection listing stays `runtime: false`.
140
141
 
@@ -146,7 +147,20 @@ The 1.5.0 cycle ships the headline **content collections** feature: turning a fo
146
147
 
147
148
  - **Pagination — `@loop … paginate N`.** A new clause (collections only) multiplies the listing into static pages: **page 1 keeps the listing's own clean route**, pages 2+ live at `/<route>/page/<n>/`. The compiler records the pagination intent (`comp.pagination`, post-`where`/`sort` page count) during a discovery compile; `src/builder.js` then recompiles each page with the correct `page` pager seeded into the document scope and emits every generated route into `routes.json` + `sitemap.xml` (RSS is dated-posts-only, unaffected). The **pager** exposed to the template scope is `{ page.current }` / `{ page.total }` (numbers) and `{ page.prev }` / `{ page.next }` (URLs, empty string at the ends) — plain `<a href>` links to the generated routes, **zero JS**. `paginate` is a positive-integer literal, rejects a non-collection source (route-multiplication only makes sense for a folder of entries), and can't combine with `offset`/`limit` (it owns the slice) or `sortable`.
148
149
 
149
- - **Demo (four-file rule):** the existing `site/pages/blog/` became a real collection — `blog/index.wd` is one paginated `@loop` over the folder (sorted date-desc, `paginate 3`, linking each entry by `{ post.url }`, with a styled `index.skin` and a `{ page.* }` pager), validated by `blog/_schema.wd`, across five genuine dated posts (so `/blog/` + `/blog/page/2/` both emit) plus a draft that proves exclusion. RSS/sitemap keep working over the collection. Tests in `tests/collections.test.js` (resolution, row shape incl. url/slug/excerpt, where/sort, draft exclusion + `--drafts` inclusion, schema pass+fail with `file:line`, pagination route-multiplication + pager vars + `/blog/page/2/` shape, `runtime: false` on a pure listing, every `paginate` guard). **Honest caveats:** `paginate N` is a build-time route split only — no client-side pagination; pagination is collections-only (a JSON-file/`:state` source is rejected); tag/taxonomy pages and image optimization are deferred to 1.6.0.
150
+ - **Demo (four-file rule):** the existing `site/pages/blog/` became a real collection — `blog/index.wd` is one paginated `@loop` over the folder (sorted date-desc, `paginate 3`, linking each entry by `{ post.url }`, with a styled `index.skin` and a `{ page.* }` pager), validated by `blog/_schema.wd`, across five genuine dated posts (so `/blog/` + `/blog/page/2/` both emit) plus a draft that proves exclusion. RSS/sitemap keep working over the collection. Tests in `tests/collections.test.js` (resolution, row shape incl. url/slug/excerpt, where/sort, draft exclusion + `--drafts` inclusion, schema pass+fail with `file:line`, pagination route-multiplication + pager vars + `/blog/page/2/` shape, `runtime: false` on a pure listing, every `paginate` guard). **Honest caveats:** `paginate N` is a build-time route split only — no client-side pagination; pagination is collections-only (a JSON-file/`:state` source is rejected); tag/taxonomy pages and image optimization are deferred to a future release.
151
+
152
+ ## 2.0.0 fixes — review follow-ups (2026-07-11)
153
+
154
+ - **BREAKING: raw HTML in markdown is escaped by default; `html: true` opts a page back in.** The markdown-it default flipped from `html: true` to `html: false` — a raw `<script>` or `onerror=` attribute in a `.md`/`.wd` body now renders as inert escaped text unless the page's frontmatter says `html: true`. Rationale: 1.5.0 content collections made multi-author markdown a first-class input, and passthrough-by-default is a stored-XSS default in that world. The opt-in is per file (pages *and* includes each carry their own frontmatter). `{ name }` interpolation is unaffected (values were and are always escaped; the binding plugin injects tokens programmatically, so it never depended on the `html` option), and fenced/inline code was always escaped. This supersedes the "global strict-by-default `html` setting remains a future option" caveat under Stage 15 — the *default* is now strict; what remains future work is only a site-wide config toggle (still no config loader).
155
+ - **CSP: `'unsafe-inline'` dropped from `script-src` on both variants.** The inline state seed is a non-executable `application/json` data block (not gated by `script-src`); the one CSP-gated inline script — the fixed `transitions: true` speculationrules block — is now authorized by a build-time `'sha256-…'` hash source plus the `'inline-speculation-rules'` keyword, both derived in `src/headers.js` and drift-guarded by a test that hashes the block out of a real build. `'unsafe-eval'` stays on reactive pages (it gates `new Function`, not inline elements — a hash cannot replace it). **Honest caveat:** an author's raw inline `<script>` on an `html: true` page is now blocked by the shipped CSP — colocated `.js` files (allowed by `'self'`) are the supported path, or widen `script-src` deliberately. `img-src`/`media-src` stay `https:`-wide by default (remote images are legitimate); SECURITY.md documents the tightening path for self-hosted-assets sites.
156
+ - **Route output containment guard.** `outputPathForRoute` asserts the resolved `index.html` path stays inside `distRoot` and throws otherwise — defense in depth for any future caller passing an unvetted route (routes from `routeFromFile` can't traverse today).
157
+ - **Accessibility & i18n triplet (compile-time only).** (1) `lang:` frontmatter sets `<html lang>` per page (escaped, default `en`). (2) Every page shell emits a visually-hidden-until-focused **skip-to-content link** as the first focusable element and guarantees its target: an author `<main>` is reused (an existing `id` wins as the skip target — a `data-id`-style `-id`-suffixed attribute is never mistaken for it; an id-less `<main>` gets `id="main"` stamped on, mirroring the `enhanceImages` never-override pattern), and a main-less body is wrapped in `<main id="main">`. The link's hide/reveal style is inline in the head (works with zero skins); `.wd-skip-link` is restylable and the site base skin ships a themed focus reveal. Static pages stay `runtime: false`. (3) `:fetch` lifecycle regions self-announce: a bare `:if <key>_loading` over a fetch-declared key compiles its if-region with `role="status" aria-live="polite"`, `:if <key>_error` with `role="alert"` — the runtime's existing show/hide flip becomes an announcement with **zero added runtime bytes** (this item ships no client JS at all). Author-supplied `role`/`aria-live` inside a region suppresses the defaults; `_loading`/`_error` state keys the author declared herself (not via `:fetch`) are never decorated.
158
+ - **Loop over a missing field is an empty list.** An in-scope `@loop` source that resolves to `undefined`/`null` (e.g. an optional frontmatter field a page omits — `tags: string[]?` in a `_schema.wd`) now loops zero rows and renders the `@empty` branch, matching the runtime's existing behavior for a missing `:state` list. A source that is **present but not a list** (a string, a number, an object) is still a compile error — now with the true `file:line` and a corrective hint. Previously a draft post omitting an optional `tags` field made `build --drafts` fatal.
159
+ - **True `file:line` in compile errors.** `parseFrontmatter` now reports the file line the body starts on (`bodyLine`), and the compile context threads that offset into `at()` — so an error after a frontmatter block points at the line an editor can jump to, not the frontmatter-stripped body line. Schema-validation errors (`_schema.wd`) already counted raw file lines and are unchanged. The same mechanism covers **nested block bodies** (`nestedCtx` accumulates a `lineOffset` on the compile context as `::: container`/`@loop` rows/`@empty`/`:if`/`:else if`/`:else`/`:form`/`:carousel` recurse via `ctx.compileBody`), so a directive error inside a block — at any depth — reports the true file line instead of one relative to the sliced block body.
160
+ - **One well-formed highlight `<link>`.** Pages with a highlighted code block previously emitted the highlight stylesheet link nested inside a second link tag's `href`; the head now carries exactly one well-formed tag (still ordered before page skins so `$code-*` overrides cascade). A dogfood test sweeps every built page for tag markup inside attribute values.
161
+ - **Honest dev-server failures.** A failed initial `darkmown dev` build now prints the full compile error prominently, the "ready" line says the build failed (instead of pretending all is well), and an HTML route with no dist output serves a build-failure page carrying the dev client (so the SSE overlay replays the error and the next successful build reloads it) instead of the production "hidden or has not been created" 404 copy.
162
+ - **Blog template on collections; scaffolded `.md` pages styled.** The `blog` template dropped its hand-maintained `site/_/posts.json` manifest (which duplicated — and contradicted — the posts' frontmatter dates): `site/pages/posts/` is a typed collection (`_schema.wd` validating `title`/`date`/`description`, optional `excerpt`, plus the page-shell `transitions` flag), and the home page is one `@loop posts into post sort by post.date desc` with an `@empty` branch. The listing's date/excerpt also render now — the old raw-HTML `<p>` card put the bindings inside a markdown `html_block`, which never interpolates. And no template ships an unstyled `.md` dead-end anymore: the starter's `about.md` and both blog posts carry colocated `.skin` siblings (the existing colocation mechanism — no new surface) and visible navigation home; a template test asserts every template's `.md` routes link a stylesheet, stay `runtime: false`, and ship zero scripts. **Honest caveat:** a colocated skin is per-file, so each new post copies a `.skin` sibling (or goes unstyled) — a shared per-directory skin remains future work.
163
+ - **Dependency-tracked incremental dev rebuilds.** Every `darkmown dev` build records a per-route dependency map into `dist/.wd-dev-deps.json` — the route's source file, every resolved `@include`, colocated `.skin`/`.js` assets, `@loop` JSON data files, and the collections it loops. On a `site/` content change the dev server passes the changed path(s) to the child build (`build --changed <path>`, internal plumbing, not public CLI surface), which rebuilds **only** the routes whose dependency graph contains the file — a collection entry (or its `_schema.wd`) also rebuilds the collection's listing/paginated consumers — while `routes.json`, `_headers`, robots/sitemap/rss are re-emitted globally every time so a partial rebuild can never leave them inconsistent (a shrunk pagination even removes its stale `/page/N/` outputs). **Correctness first:** ANY uncertainty — a new/deleted/renamed file, a file no dependency graph accounts for, a change to the site-wide feed `<link>` every page embeds, a missing/corrupt/version-mismatched map — falls back to a full rebuild, and a framework `src/` change is always a full child-process rebuild (the fresh-modules invariant is untouched). Rebuild children are **serialized** (`createRebuildQueue` in `src/dev.js`: one child at a time; changes arriving mid-build coalesce into the next batch; a `null`/full-rebuild entry swallows the batch) because every build read-modify-writes the map — overlapping children could persist a stale map missing deps a concurrent build just recorded, silently detaching routes from their dependencies. Production `darkmown build` writes no map and is unchanged.
150
164
 
151
165
  ## 1.4.0 additions — compile-time scoped styles (2026-06-28)
152
166
 
@@ -154,7 +168,7 @@ The 1.4.0 cycle ships one feature: **opt-in scoped styles** for colocated `.skin
154
168
 
155
169
  - **Authoring surface — the `scoped` marker:** a `.skin` scopes its selectors **only** when its first non-comment, non-blank, non-divider line is exactly `scoped`; colocation alone changes nothing. Every existing `.skin` (no marker) stays **global** and emits **byte-identical CSS** to before (golden-tested against `site/_/base.skin`). `scoped` anywhere but the first line is a compile error (`"scoped" must be the first line of a .skin file`). The only other new surface is `:global()` inside a scoped skin — no new `.wd` syntax.
156
170
 
157
- - **Mechanism — id + selector rewrite + HTML stamp.** The scope id is a short, deterministic hash of the skin's **project-relative path** (e.g. `wd-7c21`) — path-based (stable across content edits, unique per file), computed in `src/builder.js` (the coordination point) and re-derived identically by the HTML stamp from the same path, so `src/skin.js` stays a pure string→CSS function (`compileSkin(src, { scope })`). In scoped mode `compileSkin` appends `[data-wd-scope="<id>"]` to the **subject** (rightmost compound) of each comma-separated selector, **after** `&`-nesting resolves and **before** any trailing pseudo-class/element (`.card[data-wd-scope="…"]:hover`, never `.card:hover[…]`; `::before` likewise) — so descendant selectors scope only the subject (`.card .title` → `.title` gets the attribute) and `@media`/`@supports` wrappers are untouched. The HTML half (`stampScope` in `src/compiler/page.js`, an `enhanceImages`-style regex post-pass) stamps `data-wd-scope="<id>"` onto every opening tag of the scoped subtree that doesn't already carry one (first stamp wins, so a nested include keeps its own id). A **page**-colocated scoped skin stamps the page body; an **include**-colocated scoped skin is stamped in `handleInclude` over just that include's returned subtree — so the same scoped component repeats on a page without its scope leaking to siblings.
171
+ - **Mechanism — id + selector rewrite + HTML stamp.** The scope id is a short, deterministic hash of the skin's **project-relative path** (e.g. `wd-7c21`) — path-based (stable across content edits, unique per file), computed in `src/builder.js` (the coordination point) and re-derived identically by the HTML stamp from the same path, so `src/skin.js` stays a pure string→CSS function (`compileSkin(src, { scope })`). In scoped mode `compileSkin` appends `[data-wd-scope="<id>"]` to the **subject** (rightmost compound) of each comma-separated selector, **after** `&`-nesting resolves and **before** any trailing pseudo-class/element (`.card[data-wd-scope="…"]:hover`, never `.card:hover[…]`; `::before` likewise) — so descendant selectors scope only the subject (`.card .title` → `.title` gets the attribute) and `@media`/`@supports` wrappers are untouched. The HTML half (`stampScope` in `src/compiler/includes.js`, an `enhanceImages`-style regex post-pass) stamps `data-wd-scope="<id>"` onto every opening tag of the scoped subtree that doesn't already carry one (first stamp wins, so a nested include keeps its own id). A **page**-colocated scoped skin stamps the page body; an **include**-colocated scoped skin is stamped in `handleInclude` over just that include's returned subtree — so the same scoped component repeats on a page without its scope leaking to siblings.
158
172
 
159
173
  - **Tokens stay global.** A `tokens` / `tokens dark` / `tokens [attr]` block in a scoped skin still emits a global `:root { --name: … }` (and the dark/manual-toggle variants) — never scoped — so `var(--x)`, dark mode, `:theme`, and the `$code-*` highlight tokens keep resolving site-wide. Enforced by only touching the selector-rule construction, leaving the token/`:root` path identical.
160
174
 
@@ -170,7 +184,7 @@ The 1.3.0 cycle closes table-stakes gaps a new user expects out of the box. Trac
170
184
 
171
185
  - **Drafts + sitemap.xml + rss.xml + robots.txt:** three coupled SEO/feed capabilities sharing one draft-exclusion gate. Site identity lives in the **home page frontmatter** (no config loader this cycle): one reserved field `site_url` (absolute origin, no trailing slash) reuses the home `title`/`description` as the RSS channel fields and is the absolute prefix for — and the trigger for emitting — `sitemap.xml` + `rss.xml`. **Drafts:** `draft: true` excludes a page from a production build everywhere (not in `dist`, `routes.json`, sitemap, or rss — even a draft that *also* has a `date:`); `darkmown dev` builds/serves drafts with a dev-only visible banner injected at serve time (never in shipped HTML, like the SSE client); `darkmown build --drafts` includes them for staging. Drafts are filtered at the single route-discovery **chokepoint** (`discoverRoutes`), which now reads each file's frontmatter **once** and threads it out as `route.meta` (one source of truth for "is this a draft", and the data the feeds consume — no second parse). This is distinct from the permanent `.`/`-`/`_` filename hiding (private forever vs a publish-when-ready toggle). **sitemap.xml** (emitted whenever `site_url` is set) lists one `<url>` per built non-404 route — reactive pages included (indexable HTML) — with `<lastmod>` = frontmatter `date:` → git last-commit date of the source file → file mtime (git wrapped in try/catch so a scaffolded app or extracted tarball with no `.git` degrades to mtime, never crashes); no `<priority>`/`<changefreq>`. **rss.xml** (emitted only with `site_url` *and* at least one dated post) syndicates the pages with a `date:` (the "this is a post" signal), newest-first, capped at 20; `<description>` resolves `excerpt:` → `description:` → first paragraph of rendered prose (plain `.md` only — `.wd` directive markup is not stripped back to prose); `<pubDate>` is RFC-822; every page advertises the feed with `<link rel="alternate" type="application/rss+xml">`. **robots.txt** is *always* emitted (`User-agent: * / Allow: /`); the `Sitemap:` line appears only with `site_url`. **No `site_url`:** robots still emits (without the Sitemap line), sitemap + rss are skipped with a loud, actionable build hint, and the build never crashes. String/XML building lives in a new `src/feeds.js` (mirroring how `src/highlight.js` keeps `builder.js` lean); the `build` summary reports counts (`Built 14 routes, sitemap (14 urls), rss (6 posts) into dist`). **Zero runtime impact** — all build-time; `src/runtime.js` and the 8 KB budget are untouched. **The leak contract** (a `draft: true` page with a `date:` produces zero sitemap entries and zero rss items; `/404/` never appears in either) is regression-tested on both the production-exclusion and `--drafts`-inclusion paths, plus the git-date vs mtime-fallback paths.
172
186
 
173
- - **Build-time syntax highlighting:** fenced code blocks with a known language are highlighted at compile time via [highlight.js](https://highlightjs.org/), wired onto both markdown-it instances (the default `html: true` and the lazy `html: false` strict variant) through markdown-it's `highlight` option in `src/compiler/markdown.js`. Output is **class-based** (`hljs-*` token spans, escaped) — no inline colors — and the framework ships a small stylesheet (`/__wd/highlight.css`) that maps the common token classes onto a `$code-*` skin-token set (`code-bg`, `code-fg`, `code-keyword`, `code-string`, `code-comment`, `code-function`, `code-number`, `code-punctuation`), each `var(--code-*, fallback)`. Because those tokens carry a `tokens dark` override in the base skin, **highlighted code dark-modes for free** through the existing `tokens dark` / `:theme` system with zero JavaScript. The highlighter is **not configurable** (closed-whitelist philosophy). Emission/injection is **pay-for-what-you-use**: a per-page `assets.hasCode` flag (set when the rendered HTML carries the `hljs` marker) emits and links the stylesheet **only** on pages that contain a highlighted block, mirroring the behavior modules — a page with no code ships nothing extra. **Zero runtime impact:** highlighting is build-time HTML + CSS only (`src/runtime.js` untouched, the 8 KB runtime budget unchanged), so a page of prose plus a code block stays `runtime: false`. **Graceful degradation:** a fence with an unknown or absent language renders as plain escaped `<code>` (no throw, no highlighting); inline `` `code` `` is never highlighted; there are deliberately **no line numbers** (they break copy-paste). **Honest caveat:** the stylesheet maps a curated common subset of highlight.js classes — any token class outside that subset inherits the base `--code-fg` color, so it stays legible but is not individually themed.
187
+ - **Build-time syntax highlighting:** fenced code blocks with a known language are highlighted at compile time via [highlight.js](https://highlightjs.org/), wired onto both markdown-it instances (since 2.0.0: the default `html: false` strict instance and the lazy `html: true` raw-HTML variant) through markdown-it's `highlight` option in `src/compiler/markdown.js`. Output is **class-based** (`hljs-*` token spans, escaped) — no inline colors — and the framework ships a small stylesheet (`/__wd/highlight.css`) that maps the common token classes onto a `$code-*` skin-token set (`code-bg`, `code-fg`, `code-keyword`, `code-string`, `code-comment`, `code-function`, `code-number`, `code-punctuation`), each `var(--code-*, fallback)`. Because those tokens carry a `tokens dark` override in the base skin, **highlighted code dark-modes for free** through the existing `tokens dark` / `:theme` system with zero JavaScript. The highlighter is **not configurable** (closed-whitelist philosophy). Emission/injection is **pay-for-what-you-use**: a per-page `assets.hasCode` flag (set when the rendered HTML carries the `hljs` marker) emits and links the stylesheet **only** on pages that contain a highlighted block, mirroring the behavior modules — a page with no code ships nothing extra. **Zero runtime impact:** highlighting is build-time HTML + CSS only (`src/runtime.js` untouched, the 8 KB runtime budget unchanged), so a page of prose plus a code block stays `runtime: false`. **Graceful degradation:** a fence with an unknown or absent language renders as plain escaped `<code>` (no throw, no highlighting); inline `` `code` `` is never highlighted; there are deliberately **no line numbers** (they break copy-paste). **Honest caveat:** the stylesheet maps a curated common subset of highlight.js classes — any token class outside that subset inherits the base `--code-fg` color, so it stays legible but is not individually themed.
174
188
 
175
189
  ## 1.2.0 additions — backends, deploy & interactions (2026-06-27)
176
190
 
@@ -192,4 +206,4 @@ The compiler recognizes three directives — `:note`, `:try`, and `:sprint` —
192
206
 
193
207
  Darkmown is faithful to the core thesis: Markdown remains the authoring center, static pages ship zero framework JavaScript, and reactive behavior compiles into tiny direct-DOM islands only where declared. The extension is the feature gate, the loop is one concept with progressive disclosure, and state scopes to document structure.
194
208
 
195
- As of **v1.0.0** the public npm package is published as `@zvndev/darkmown`, the project is MIT licensed, and the directive surface is stable. The 1.0 release closes the audit-flagged display/time/theme/sort/media/escape-hatch gaps; the remaining items above are explicitly scoped as post-launch product/distribution work rather than missing core claims.
209
+ As of **v1.0.0** the public npm package is published as `@zvndev/darkmown`, the project is MIT licensed, and the directive surface is stable. The 1.0 release closes the audit-flagged display/time/theme/sort/media/escape-hatch gaps; the remaining items above are explicitly scoped as post-launch product/distribution work rather than missing core claims. **2.0.0** made one breaking change to that stable surface — raw HTML in markdown is now escaped by default (`html: false`), with `html: true` the per-page opt-in — a security default flip, not a directive change (see the 2.0.0 entry above and CHANGELOG).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "1.5.0",
3
+ "version": "2.0.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -35,7 +35,8 @@
35
35
  "prepare": "husky || true",
36
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",
37
37
  "prepublishOnly": "npm run prepublish:check",
38
- "og": "node scripts/og-image.mjs"
38
+ "og": "node scripts/og-image.mjs",
39
+ "bench:build": "node scripts/bench-build.mjs"
39
40
  },
40
41
  "lint-staged": {
41
42
  "*.{js,json}": "biome check --no-errors-on-unmatched --files-ignore-unknown=true"