@zvndev/darkmown 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -41,8 +41,8 @@ package.json
41
41
  - Files/folders starting with `.`, `-`, or `_` are **hidden** from routing (drafts, partials).
42
42
  - A `page.skin` next to `page.wd` attaches styling; a `page.js` attaches behavior. Matched by basename.
43
43
  - **Colocated assets:** any other file under `site/pages/` (not a `.md`/`.wd` route, not a basename-matched `.skin`/`.js`) copies to `dist/` at its own path with the right content-type — so `site/pages/logo.svg` is served at `/logo.svg`. Use the `site/_/` shelf for assets shared across pages; colocate the ones a single page owns.
44
- - **Responsive image variants:** export pre-sized copies next to the source image, same directory, same extension, `@<width>` before it (`dr-x@640.webp`, `dr-x@1280.webp`), and a Markdown image gains a `srcset` automatically, the original kept as `src`. Widths are MEASURED from file headers (a `@640` file really holding 600px emits `600w`; the filename is only a fallback), candidates sort ascending, and discovery probes a fixed width list (64 through 3840: 64, 96, 128, 160, 256, 320, 480, 640, 768, 960, 1024, 1280, 1440, 1536, 1920, 2048, 2560, 3840), so an off-list width is a documented miss, never an error. `sizes` is never emitted (write raw `<img srcset sizes>` on an `html: true` page for that). Never wired: bound srcs (`![a]({ photo })`), remote/`data:` sources, query/hash srcs, page-relative or extensionless srcs, and raw-HTML `<img>` tags. Variant files are ordinary colocated assets (they copy to `dist/`, never routes); shelf images resolve variants against `/__wd/media/`. The first image on the page stays eager with `fetchpriority="high"`, the rest lazy-load, and author-set attributes always win. `.md` pages get the width/height/lazy hardening but no srcset.
45
- - Shelf `.json` files publish to `/__wd/data/<name>`; every other shelf file publishes to `/__wd/media/<path>`.
44
+ - **Responsive image variants:** with sharp installed (`npm i -D sharp`, an optional dependency; builds work without it), the build scans `site/pages` for raster photos (jpg/jpeg/png/webp) over 700px wide and writes resized `name@640.ext` / `name@1280.ext` variants into `dist`, wiring a `srcset` onto the Markdown image with the original kept as `src`. Only widths below the intrinsic size are generated (never upscale), variants keep the source format, and EXIF orientation is applied. **Author-provided `name@<width>.ext` exports win per width**: your file is copied verbatim and measured from its own header (a `@640` file holding 600px emits `600w`), and generation fills only the uncovered widths; other sizes are hand exports on the probe list (64, 96, 128, …, 3840). Skipped silently: images ≤700px, `@width` files, hidden/symlinked paths, svg/gif/avif, and shelf images (the generator covers page-colocated images only). Without sharp the build behaves as before plus one `npm i -D sharp` hint per build; a corrupt image or a sharp failure on one file is a hint naming the file, never a build failure. Resizes cache in `node_modules/.cache/darkmown-images/` (content hash + width + format), so rebuilds and dev reloads do zero sharp work for unchanged images. Never wired: bound srcs (`![a]({ photo })`), remote/`data:`/query-hash srcs, page-relative or extensionless srcs, and raw-HTML `<img>` tags; `sizes` is never emitted (raw `<img srcset sizes>` on an `html: true` page is the way). `.md` pages get the width/height/lazy hardening but no srcset. The first image on the page stays eager with `fetchpriority="high"`, the rest lazy-load, and author-set attributes always win.
45
+ - Shelf `.json` files publish to `/__wd/data/<name>`; every other shelf file publishes to `/__wd/media/<path>`. Hidden segments (`.`, `-`, `_`) and symlinks are never published, matching the page-asset rule; a symlink that stays inside `site/_/` still works as an `@include`/`@loop` source (it unrolls into pages at build time), it is just never copied to `dist`.
46
46
 
47
47
  Commands: `darkmown dev`, `darkmown build [--drafts]`, `darkmown validate` (compile-check without writing `dist`), `darkmown serve` (previews `dist`, runs `api/` functions like dev, and compresses text responses), `darkmown deploy <vercel|cloudflare>`, `darkmown catalog [--llms|--llms-full]`.
48
48
 
@@ -78,6 +78,7 @@ rss_limit: 20
78
78
  - `noindex: true` builds the page but removes it from discovery: a robots `noindex,nofollow` meta, and exclusion from `sitemap.xml`, `rss.xml`, and the generated `llms.txt`/`llms-full.txt`. On a collection entry with a `_schema.wd`, declare it as `noindex: boolean?`.
79
79
  - `icon: /path` sets the favicon verbatim (a `type` is added for `.svg`/`.png`/`.ico`). With no `icon:` key, the first of `site/pages/icon.svg`, `icon.png`, `favicon.ico` wins; with neither, the built-in mark is the fallback.
80
80
  - `skins: [/base.skin]` (an inline array, same parsing as `tags:`) loads shared `.skin` files for that page only. Paths resolve like `@include` paths; the file must exist, end in `.skin`, and stay inside `site/pages`/`site/_`, or the build fails with `[WD017]`. One scoped skin per page: a shared `scoped` skin scopes to the whole page body.
81
+ - `defaults: [name="value"]` (include files only) sets fallback values for `with` arguments; see Includes & sections.
81
82
  - `date` marks the page a post (RSS item, sitemap `lastmod`, `og:type=article`). `updated` and `excerpt` refine that. `author` feeds an article `schema:`.
82
83
  - `schema:` emits JSON-LD. One value, or a list. The closed set: `Article`, `BlogPosting`, `TechArticle`, `WebSite`, `Organization`. `organization:` and `logo:` support the `Organization` type. Anything else is a compile error.
83
84
  - **Home page only:** `site_url: https://example.com` (no trailing slash) turns on `sitemap.xml`, `rss.xml`, and canonical URLs. `ai_crawlers: allow` (default) or `deny` flips every AI-crawler group in `robots.txt`. `rss_limit: 50` sets how many newest posts `rss.xml` carries (default 20).
@@ -125,7 +126,7 @@ Pipes also fold inside **raw HTML** on an `html: true` page (attribute values an
125
126
 
126
127
  ## Directives — the complete set (nothing else exists)
127
128
 
128
- Twenty-five directives. `@include`, `@loop`, `:::`, `:if`, `:state`, `:store`, `:computed`, `:fetch`, `:effect`, `:every`, `:theme`, `:button`, `:form`, `:input`, `:textarea`, `:select`, `:checkbox`, `:radio`, `:submit`, `:bind`, `:slider`, `:video`, `:audio`, `:embed`, `:carousel`. Block forms close with `@endloop`, `:endif`, `:endform`, `:endcarousel`, or a bare `:::`. Run `darkmown catalog` for the same list as JSON.
129
+ Twenty-five directives. `@include`, `@loop`, `:::`, `:if`, `:state`, `:store`, `:computed`, `:fetch`, `:effect`, `:every`, `:theme`, `:button`, `:form`, `:input`, `:textarea`, `:select`, `:checkbox`, `:radio`, `:submit`, `:bind`, `:slider`, `:video`, `:audio`, `:embed`, `:carousel`. Block forms close with `@endloop`, `:endif`, `:endform`, `@endinclude`, `:endcarousel`, or a bare `:::`. Run `darkmown catalog` for the same list as JSON.
129
130
 
130
131
  ### State, stores, and computed
131
132
  ```wd
@@ -315,12 +316,22 @@ The closed vocabulary: `string`, `number`, `boolean`, `date`, `string[]`, each w
315
316
  @include /nav.wd
316
317
  @include /card.wd with title="Aurora"
317
318
 
319
+ @include /card.wd with title="Pricing"
320
+
321
+ Children compile in the caller's scope and land at the include's standalone { children } line.
322
+
323
+ @endinclude
324
+
318
325
  ::: section #cart .panel
319
326
  :state items = []
320
327
  { items.length } items
321
328
  :::
322
329
  ```
323
- - `@include /partial.wd [with key="value" …]` inlines a file from `site/_/` or `site/pages/`. Paths are **static literals**; there is no dynamic include path. Includes are macros, not components: no children, no slots, no default arguments. A missing argument renders `{ title }` literally.
330
+ - `@include /partial.wd [with key="value" …]` inlines a file from `site/_/` or `site/pages/`. Paths are **static literals**; there is no dynamic include path. Includes are compile-time macros with one slot (below); a missing argument with no default renders `{ title }` literally.
331
+ - **`defaults:` frontmatter** (include files only) sets fallback values for `with` arguments: `defaults: [title="Untitled", cta="Learn more"]`. The caller's `with` value always wins; values are strings (`count="3"` is `"3"`, while a caller's `with count=5` stays typed); entries split on top-level commas only, so `cta="Learn more, today"` works. A malformed `defaults:` is `[WD613]`.
332
+ - **Block form:** `@include /card.wd with title="Pricing"` … `@endinclude` compiles the enclosed lines once in the CALLER's scope (its `:state`, and the loop item when the call sits inside a `@loop`) and inlines them where the include puts a standalone `{ children }` line. Zero runtime cost. Called without a block, the slot renders empty; a block given to an include with no `{ children }` line warns that the content is dropped.
333
+ - `{ children }` is a standalone line only: mid-paragraph or fenced it stays literal, and on a top-level page it is literal text. A `.md` include cannot host a slot. An include may place `{ children }` more than once; the children still compile once.
334
+ - **Pairing:** `@endinclude` closes the nearest preceding unpaired `@include`, so an inline `@include` between opener and closer consumes the pairing (keep inline includes outside a block's span). One level of block nesting is supported; deeper is `[WD616]`, and a stray closer is `[WD614]`. There is NO unclosed-block error: an unpaired opener is simply the inline form, which must stay legal.
324
335
  - `::: [tag] [.class …] [#id] [.class when …] [role="…"] [aria-…="…"] [title="…"] … :::` groups content. A container named `section`, `nav`, or `main` emits that real element; any other name is a `<div>` with that class. State declared inside a section is scoped to it (`sectionId:name`).
325
336
  - Container attributes are **exactly three names** (`role=`, any `aria-…=`, `title=`), always double-quoted static text, interleaved freely with `.class`/`#id` tokens: `::: nav .menu role="navigation" aria-label="Main"`. Anything else (`class=`, `id=`, `style=`, `onclick=`, `data-…=`) is `[WD650]`; a missing quoted value is `[WD651]`. No `{ state }` inside an aria value: it emits the literal braces. Zero runtime cost, a static page stays static.
326
337
  - There is **no layout or shell inheritance**. Every page includes its own nav and footer.
@@ -596,7 +607,7 @@ Always include: a tokens block, a real type scale, generous spacing, a `max-widt
596
607
 
597
608
  **Syntax highlighting is free.** A fenced code block tagged with a language is highlighted at build time (no client JS), and its token classes read `$code-*` tokens from your skin, so highlighted code follows dark mode with no extra wiring.
598
609
 
599
- **`@font-face` and `@keyframes` are skin at-rules.** Their indented children are font descriptors and keyframe selectors, not page selectors, so they compile as self-contained blocks. `@font-face` takes plain declarations (`font-family "Newsreader"`, `src url("/fonts/n.woff2") format("woff2")`, `font-weight 200 800`, `font-display swap`). `@keyframes <name>` requires a plain identifier; each indented child is a keyframe selector (`from`, `to`, a percentage, or a comma list) with its own declarations one level deeper, and anything else is a compile error. Aliases and `$token` refs work inside both, both stay **global** in a `scoped` skin (same contract as `tokens`), and both can nest under an `@media` line (the home for `prefers-reduced-motion` animation guards).
610
+ **`@font-face` and `@keyframes` are skin at-rules.** Their indented children are font descriptors and keyframe selectors, not page selectors, so they compile as self-contained blocks. `@font-face` takes plain declarations (`font-family "Newsreader"`, `src url("/fonts/n.woff2") format("woff2")`, `font-weight 200 800`, `font-display swap`). `@keyframes <name>` requires a plain identifier; each indented child is a keyframe selector (`from`, `to`, a percentage, or a comma list) with its own declarations one level deeper, and anything else is a compile error. Aliases and `$token` refs work inside both, both stay **global** in a `scoped` skin (same contract as `tokens`), and both can nest under an `@media` line (the home for `prefers-reduced-motion` animation guards). With `harfbuzzjs` + `wawoff2` installed, project-local font srcs are also subsetted to woff2 at build time (see the fonts recipe below).
600
611
 
601
612
  **Stylesheet order (changed in 2.8):** include skins compile first, then the page's `skins:` entries in listed order, and the page's own colocated `.skin` LAST, so the page wins ties at equal specificity. Before 2.8 the page's skin came first; delete any specificity hacks that only existed to beat the old order.
602
613
 
@@ -660,12 +671,12 @@ Style `summary` as the hamburger and `.menu-drawer[open]` as the drawer in the s
660
671
 
661
672
  ### Fonts done right
662
673
 
663
- Raw TTFs are the most expensive asset a marketing site ships: in the measured rebuild, three variable TTFs cost 1.07 MB on the homepage where the original site served 93 to 158 KB of subset woff2 for the same two families, roughly a 7x difference. The recipe:
674
+ Raw TTFs are the most expensive asset a marketing site ships: in the measured rebuild, three variable TTFs cost 1.07 MB on the homepage where the original site served 93 to 158 KB of subset woff2 for the same two families, roughly a 7x difference. Two ways to get there:
675
+
676
+ 1. **Let the build do it:** `npm i -D harfbuzzjs wawoff2` (both optional dependencies) and a post-pass subsets every project-local `.ttf`/`.otf`/`.woff` in an `@font-face src url(...)` to the site's charset (every code point in the emitted HTML, plus a base set of ASCII, Latin-1, punctuation, arrows, and ★ ✓ ✗), emits `<name>-subset.woff2` beside the original, and rewrites the dist CSS to list the subset first. Cached in `node_modules/.cache/darkmown-fonts/`, idempotent under dev rebuilds, and absent the toolchain you get one hint per build and unchanged output. Measured on the rebuild: 1.07 MB of TTFs became 288 KB of woff2.
677
+ 2. Or do it by hand: convert to **woff2**, subset to the glyphs the site uses, and ship the result yourself.
664
678
 
665
- 1. Convert every font to **woff2** and **subset** it to the glyphs the site actually uses.
666
- 2. Serve it from the shelf (`site/_/fonts/`, published at `/__wd/media/fonts/`) or colocate it under `site/pages/`.
667
- 3. Declare it with `@font-face` in a shared skin, always with `font-display swap`.
668
- 4. Preload the one critical face with a raw `<link rel="preload" as="font" type="font/woff2" crossorigin>` on an `html: true` page, or an HTTP `Link` header on the host.
679
+ Either way: serve fonts from the shelf (`site/_/fonts/`, published at `/__wd/media/fonts/`) or colocate them, declare them with `@font-face` in a shared skin, always with `font-display swap`, and preload the one critical face with a raw `<link rel="preload" as="font" type="font/woff2" crossorigin>` on an `html: true` page, or an HTTP `Link` header on the host.
669
680
 
670
681
  ### Pre-fill a form message from query params
671
682
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
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.10.0 (2026-10-14)
6
+
7
+ A release about composition and the last of the rebuild's asset costs. Includes grew the two things a component model needs (default arguments and a children slot) without leaving the compile-time macro model: no runtime marker, no `src/runtime.js` change, a static page stays static. Fonts now subset to woff2 at build time, closing the 7x gap the client rebuild measured. And a security pass hardened four publisher gaps, plus the shipped CSP now follows each page's `:embed` hosts. Three new error codes (`WD613`, `WD614`, `WD616`; `WD615` is deliberately unassigned), no breaking change.
8
+
9
+ - **Includes take default arguments and a `{ children }` slot.** An include file declares fallbacks in its own frontmatter (`defaults: [title="Untitled", cta="Learn more"]`); the caller's `with` value always wins, values are strings (a caller's typed `with count=5` still wins as-is), entries split on top-level commas only, and a malformed `defaults:` is `WD613`. The block form, `@include /card.wd with title="Pricing"` … `@endinclude`, compiles the enclosed lines once in the CALLER's scope (its `:state`, and the loop item when the call sits inside a `@loop`, so per-row components stay zero-JS over a static source) and inlines them wherever the include puts a standalone `{ children }` line. Called without a block the slot renders empty; a block given to an include that never uses `{ children }` is dropped with a build warning; `{ children }` mid-paragraph, inside a fence, or on a top-level page stays literal; a `.md` include cannot host a slot. Pairing is pinned: `@endinclude` closes the nearest preceding unpaired `@include`, which means an inline `@include` between opener and closer consumes the pairing (keep inline includes outside a block's span). One level of block nesting is supported (`WD616` deeper), a stray closer is `WD614`, and there is deliberately NO unclosed-block error: an unpaired opener is the inline form, which every pre-feature page relies on, so the two cannot be distinguished without false positives. `WD615` stays unassigned for a future unclosed-detection rule that can be.
10
+ - **Build-time font subsetting to woff2.** With the wasm toolchain installed (`npm i -D harfbuzzjs wawoff2`, both `optionalDependencies`, so `dependencies` stays empty), a post-pass scans `dist/**/*.css` for `@font-face` blocks, subsets every project-local `.ttf`/`.otf`/`.woff` src to the site's charset (every code point in the emitted HTML, numeric character references decoded, plus a base set of ASCII, Latin-1, punctuation, arrows, and ★ ☆ ✓ ✕ ✗ so runtime-injected text stays covered), encodes woff2, emits `<name>-subset.woff2` beside the original, and rewrites the dist CSS to list the subset first. The source `.skin` is never touched, layout features (kern, liga, calt) and variable axes survive, and the rewrite is idempotent so incremental dev builds are safe. **BEHAVIOR CHANGE: a build with the toolchain installed now subsets fonts automatically; without it, nothing changes** except one hint per build naming the fonts that could be subsetted. A font that fails to parse ships as-is with its own hint; the build never fails over a font. Subsets cache in `node_modules/.cache/darkmown-fonts/` keyed by font and charset hashes, and the build summary gains a `fonts (N subsetted, M cached)` clause. The toolchain runs in a `spawnSync` child process (the build is synchronous, the wasm is not), same seam as the image-variant sharp integration. Measured on the 67-route rebuild: 1.07 MB of variable TTFs became 288 KB of subset woff2, 3.7x smaller, with a ~9.6 s first build and ~1.0 s cached rebuilds.
11
+ - **SECURITY / BEHAVIOR CHANGE: shelf assets no longer publish hidden segments or symlinks.** `site/_/` publishing now matches the page-asset rules exactly: a path segment starting with `.`, `-`, or `_` is skipped (previously `site/_/.env.backup` was copied verbatim to `dist/__wd/media/`), and symlinks on the shelf are never followed (previously a symlink copied its target's bytes into `dist`). A symlink that stays inside `site/_/` still works as an `@include`/`@loop` source, so a symlinked `@loop` JSON unrolls into pages at build time but no longer appears at `/__wd/data/<name>`. Both the full build and the incremental dev re-copy enforce this.
12
+ - **SECURITY: `darkmown serve` and `darkmown dev` answer 404 for a URL with a NUL byte.** `%00` used to throw `ERR_INVALID_ARG_VALUE` out of the request handler and kill the server; a decoded NUL is now a plain miss. Traversal containment is unchanged.
13
+ - **SECURITY: the generated Cloudflare worker no longer echoes handler error messages.** A throwing `api/` handler is logged server-side (`console.error`, which lands in the host's logs) and answered `500 { ok: false, error: "Internal error" }` instead of echoing `err.message`, which can carry paths, query text, or connection strings. The dev api-runner keeps the verbose message, because in dev the developer is the audience.
14
+ - **SECURITY: compile-time image dimension reads are sandboxed like `@include`.** `measureImage` now enforces the same lexical plus symlink-canonical containment as `WD601`, so an image src resolving outside `site/pages`/`site/_` (or a symlinked image pointing out) behaves exactly like a missing image: no dimensions, no throw, the file never read.
15
+ - **CSP `frame-src` unions each page's `:embed` hosts per route.** YouTube and Vimeo stay pre-authorized on every page; any other embed host (a NexHealth-style booking iframe, a checkout frame) is now authorized automatically on exactly the pages that embed it, deduped against the base list, with a same-origin embed recording `'self'`. Pages with no extra hosts keep a byte-identical CSP (and a no-embed site's `routes.json` is unchanged). The union ships in Cloudflare's `dist/_headers` and is honored by `darkmown serve`; **on Vercel, widen `frame-src` in your own `vercel.json`**, because Vercel reads it before the build runs, the same pattern as the existing `connect-src` guidance.
16
+ - **Documentation: the site gained a [/benchmarks/](https://darkmown.com/benchmarks/) page** with the measured ToothDocs comparison the 2.8.0 recipes and this release's numbers come from.
17
+
18
+ ## 2.9.0 (2026-10-13)
19
+
20
+ - **Authoring/Assets: responsive image variants are generated at build time.** With [sharp](https://sharp.pixelplumbing.com/) installed (`npm i -D sharp`, declared as an `optionalDependency`, so `dependencies` stays empty and every build works without it), the builder scans `site/pages` for raster photos (jpg/jpeg/png/webp) wider than 700px and writes resized `name@640.ext` / `name@1280.ext` siblings into `dist`, handing the compiler a variant map that wires the `srcset` exactly like author-provided variants: the original stays the `src` fallback and joins the srcset at its intrinsic width. Only widths below the intrinsic size are generated (never upscale), variants keep the source format (webp and jpeg at quality 82, jpeg progressive; png stays png at maximum compression), and EXIF orientation is applied before resizing, so rotated phone photos come out upright. **BEHAVIOR CHANGE: a build with sharp installed now generates variants automatically** where before it shipped the original alone; **without sharp, nothing changes** except one hint per build naming the images that could gain variants (`Run npm i -D sharp to generate them at build time.`). The 2.8.0 `@width` convention becomes the override: an author-provided `name@640.ext` is copied verbatim, measured from its own header, and never overwritten, and generation fills only the widths the author left uncovered. Skipped silently: images at or under 700px, `@width` files themselves, hidden and symlinked paths, svg/gif/avif, and shelf images under `site/_`. A corrupt image (dimensions unreadable) or a sharp failure on one file prints a hint naming that file; the build never fails because of an image. Resizes cache in `node_modules/.cache/darkmown-images/` keyed by content hash + width + format (already gitignored with `node_modules`), so a second build does zero sharp work and a fully cached build never loads sharp; dev rebuilds recompute the variant map from the cache, and changing an image forces a full rebuild, exactly as before. The build summary gains an `image variants (N generated, M cached)` clause whenever variants materialized. The build is synchronous and sharp is not, so the resizes run in one child process per build, and a fully cached build spawns nothing.
21
+
5
22
  ## 2.8.0 (2026-10-13)
6
23
 
7
24
  A release from a real client-site rebuild: a 67-route marketing site rebuilt in Darkmown, with every friction point the rebuild reported either fixed or written down as a recipe. The `.skin` language learned `@font-face` and `@keyframes`, frontmatter gained favicon, shared-skin, and discovery opt-out keys, format pipes now fold inside raw HTML, and `darkmown serve` reached parity with dev for `api/` functions while adding response compression. One deliberate behavior change (the stylesheet cascade order) is called out below with its migration note. Three new error codes (`WD017`, `WD706`, `WD707`), no breaking API change.
package/README.md CHANGED
@@ -39,8 +39,8 @@ no install and no build step.
39
39
 
40
40
  **On this page**
41
41
 
42
- - [Quick start](#quick-start) · [Showcase](#showcase) · [Working from this repo](#working-from-this-repo) · [Commands](#commands)
43
- - **Authoring:** [Authoring model](#authoring-model) · [Directive lines](#directive-lines) · [Interpolation](#interpolation) · [Frontmatter](#frontmatter) · [Static assets](#static-assets) · [SEO & feeds](#seo--feeds-sitemap-rss-robots) · [Loops](#loops) · [Content collections](#content-collections) · [Sections](#sections)
42
+ - [Quick start](#quick-start) · [Showcase](#showcase) · [Benchmarks](#benchmarks) · [Working from this repo](#working-from-this-repo) · [Commands](#commands)
43
+ - **Authoring:** [Authoring model](#authoring-model) · [Directive lines](#directive-lines) · [Includes](#includes) · [Interpolation](#interpolation) · [Frontmatter](#frontmatter) · [Static assets](#static-assets) · [SEO & feeds](#seo--feeds-sitemap-rss-robots) · [Loops](#loops) · [Content collections](#content-collections) · [Sections](#sections)
44
44
  - **Reactivity:** [Reactive directives](#reactive-directives) · [Fetching data](#fetching-data) · [Global state](#global-state--store) · [URL as state](#url-as-state-from-url) · [Forms and persistence](#forms-and-persistence) · [Backends & deploy](#backends--deploy) · [Interactions](#interactions--slider-sortable-carousel)
45
45
  - **Presentation:** [Inline attributes](#inline-attributes) · [Media](#media--video-audio-embed) · [Syntax highlighting](#syntax-highlighting) · [Dark mode](#dark-mode--tokens-dark) · [Scoped styles](#scoped-styles--scoped) · [Skin at-rules](#skin-at-rules-font-face-and-keyframes)
46
46
  - **Extending:** [The escape hatch](#the-escape-hatch) · [Programmatic compile](#programmatic-compile--compilefrommemory) · [Compile error codes](#compile-error-codes) · [AI authoring](#ai-authoring) · [Editor support](#editor-support)
@@ -58,6 +58,16 @@ Five complete apps, each a readable `.wd` file — see them at **[darkmown.com/s
58
58
 
59
59
  Plus focused feature demos: a draggable, keyboard-navigable **[Swiper](https://darkmown.com/carousel/)** (the `wd.subscribe` escape hatch) and a zero-JS **[Media](https://darkmown.com/media/)** page (`:video` / `:audio` / `:embed`).
60
60
 
61
+ ## Benchmarks
62
+
63
+ We rebuilt a live production marketing site (Next.js 16, React 19, Tailwind, on Vercel) in Darkmown: same copy, same images, same CMS content pipeline, 67 routes. Then we measured both sides, median of three loads per route. Full tables, losses included, at **[darkmown.com/benchmarks](https://darkmown.com/benchmarks/)**:
64
+
65
+ - **JavaScript per page:** 225-227 KB compressed across 16 identical chunks on every Next.js page, vs 18-23 KB total in Darkmown (the whole framework runtime is 6.6 KB gzipped).
66
+ - **Requests per page:** 46-70 vs 11-20. The live site also loads ~357 KB of third-party JS (GTM, DoubleClick); the rebuild loads zero.
67
+ - **Build time (same machine):** 16.6 s cold / 23.2 s warm vs 2.7-3.6 s for all 67 routes, with no build cache.
68
+
69
+ The honest losses, because a benchmark that only wins is marketing: the rebuild shipped 1.07 MB of raw TTF fonts against `next/font`'s 158 KB of subset woff2, and 1,045 KB of unoptimized photos against 147 KB of CDN width variants. Fonts now have a documented woff2 + subsetting recipe (2.8), and responsive image variants are generated at build time (2.9).
70
+
61
71
  ## Working from this repo
62
72
 
63
73
  ```sh
@@ -98,6 +108,79 @@ A directive is a whole line, written flush against the left margin. Four rules c
98
108
  - **To show a directive name as text, escape it.** `\:fetch` renders the literal text `:fetch`. That is the ordinary CommonMark backslash escape, so it works anywhere Markdown does, including inside a container.
99
109
  - **There is no comment syntax.** A trailing `#` or arrow note on a directive line is parsed as part of the value, not as a comment. `html: true ← required` seeds the *string* `true ← required`, which compiles clean while doing the opposite of what it looks like. Put explanations in the prose around the line.
100
110
 
111
+ ## Includes
112
+
113
+ `@include /nav.wd` inlines a file from the `site/_/` shelf (or `site/pages/`); `@include ./-note.wd` resolves beside the current page. Arguments pass Liquid-style: `@include /card.wd with title="Hello" count=3`, and `with title={ feature.title }` passes a value already in scope, including whole objects. Includes inside a loop inherit the loop value automatically. Includes are compile-time macros: they expand at build time, so a static page stays static and there is no runtime marker.
114
+
115
+ ### Default values for include arguments
116
+
117
+ An include declares fallbacks for its `with` arguments in its own frontmatter, so every optional argument has a value even when the caller passes nothing:
118
+
119
+ ```wd
120
+ ---
121
+ defaults: [title="Untitled", cta="Learn more"]
122
+ ---
123
+
124
+ ::: banner
125
+ ## { title }
126
+
127
+ [Read the docs](/docs/) — { cta }
128
+ :::
129
+ ```
130
+
131
+ - A caller's `with title="…"` always wins; the default fills in only when the caller is silent. An argument with neither a caller value nor a default keeps the old behavior: it renders as literal `{ title }` text.
132
+ - Values are strings, because frontmatter is string-typed: `count="3"` is the string `"3"`, while a caller can still pass a typed `with count=5` and it wins as-is.
133
+ - Entries split on top-level commas only, so a value may contain a comma (`cta="Learn more, today"`); single quotes work too (`cta='Go'`).
134
+ - Defaults scope like `with` arguments: they apply inside the include that declares them, including includes nested inside its body.
135
+ - A malformed `defaults:` (not an inline array, or an entry that is not `name="value"`) is `[WD613]`.
136
+
137
+ ### The children slot: `@include … @endinclude`
138
+
139
+ The block form turns an include into a real wrapper component. The lines between the call and `@endinclude` compile once, in the **caller's** context (its scope, its `:state`, and the loop item when the call sits inside a `@loop`), and land wherever the include puts a standalone `{ children }` line:
140
+
141
+ ```wd
142
+ @include /card.wd with title="Pricing"
143
+
144
+ Everything in **this paragraph** is the card body.
145
+
146
+ @endinclude
147
+ ```
148
+
149
+ with `site/_/card.wd`:
150
+
151
+ ```wd
152
+ ---
153
+ defaults: [title="Untitled"]
154
+ ---
155
+
156
+ ::: card
157
+ ## { title }
158
+
159
+ { children }
160
+ :::
161
+ ```
162
+
163
+ Rules worth knowing:
164
+
165
+ - **Caller scope, build time.** Children lines compile as if written where the call is, so `Price: { product.price | money }` inside a loop's block sees the loop row, and the whole thing stays zero-JS when the source is static.
166
+ - **The slot is a standalone line.** `{ children }` written mid-paragraph is literal text, one inside a fenced code block is documentation rather than a slot, and one on a top-level page (not an include) stays literal too. A `.md` include cannot host a slot at all.
167
+ - **Called without a block, the slot renders empty.** An include may place `{ children }` more than once; the children still compile once, so state a child line declares is declared once in the caller.
168
+ - **A block given to an include that never uses `{ children }` is dropped with a build warning**, so a forgotten slot is loud rather than silent.
169
+ - **Pairing: `@endinclude` closes the nearest preceding unpaired `@include`.** The caveat that follows: an inline `@include` written between a block opener and its `@endinclude` is itself the nearest unpaired opener and consumes the closer, so keep inline includes outside a block's span. One level of block nesting is supported (a block inside children, or inside a nested include's body); deeper is `[WD616]`. A closer with no unpaired opener is `[WD614]`.
170
+ - **There is deliberately no "unclosed block" error.** An `@include` that never pairs with an `@endinclude` IS the inline form, and the inline form must stay legal (every pre-feature page is full of `@include /nav.wd` followed by prose, which is syntactically identical to an unclosed block), so the two cannot be distinguished without false positives. Unpaired opener = inline include, unpaired closer = `[WD614]`, a slotted include called without a block renders its slot empty. Treat "close every block" as an authoring rule, not an error.
171
+
172
+ A component per loop row is the payoff pattern, and it stays zero-JS over a static source:
173
+
174
+ ```wd
175
+ @loop /products.json into product
176
+ @include /card.wd with title={ product.name }
177
+
178
+ Price: { product.price | money }
179
+
180
+ @endinclude
181
+ @endloop
182
+ ```
183
+
101
184
  ## Interpolation
102
185
 
103
186
  One syntax everywhere: `{ name }` or `{ name.path }`.
@@ -215,28 +298,30 @@ Reference them with a normal URL: `![logo](/__wd/media/logo.svg)`. The preview/`
215
298
 
216
299
  **Images are hardened at compile time.** Every `<img>` the compiler emits is stamped with its intrinsic `width`/`height` (read from the source file, so the browser reserves space and the page doesn't reflow as images decode), `decoding="async"`, and a load-priority split — the first image on the page stays eager with `fetchpriority="high"` (the LCP candidate), the rest get `loading="lazy"`. Author-set attributes always win, and remote or unreadable sources just skip the dimensions. The compiler **measures**, it doesn't resize — keep source images web-sized (modern formats, sensible dimensions).
217
300
 
218
- **Responsive variants via the `@width` convention.** Darkmown still resizes nothing, but it wires the variants you already exported. Put pre-sized copies next to the source, same directory, same extension, `@<width>` before it (`dr-x.webp` beside `dr-x@640.webp` and `dr-x@1280.webp`), and a Markdown image gains a `srcset` with the original kept as `src`:
301
+ **Responsive variants, generated at build time.** Drop one photo into `site/pages` and the build does the rest: with [sharp](https://sharp.pixelplumbing.com/) installed (`npm i -D sharp`, an optional dependency; every build works without it), each raster image large enough to matter is resized to the default widths **640 and 1280**, written into `dist/` as `name@<width>.ext` siblings, and the Markdown image that references the original gains a `srcset`:
219
302
 
220
303
  ```html
221
- <img src="/doctors/dr-x.webp" alt="Dr X"
222
- srcset="/doctors/dr-x@640.webp 600w, /doctors/dr-x@1280.webp 1280w, /doctors/dr-x.webp 2400w"
304
+ <img src="/doctors/dr-aurora.jpg" alt="Dr. Aurora"
305
+ srcset="/doctors/dr-aurora@640.jpg 640w, /doctors/dr-aurora@1280.jpg 1280w, /doctors/dr-aurora.jpg 2400w"
223
306
  width="2400" height="1600" decoding="async" fetchpriority="high">
224
307
  ```
225
308
 
226
- - **Descriptor widths are measured, not parsed from the filename.** Each variant's intrinsic width is read from its file header (PNG/JPEG/GIF/WebP/SVG), so a file named `@640` that really holds 600px emits `600w`; the filename width is only the fallback when the bytes cannot be read. The original joins the srcset at its intrinsic width when that can be measured, and all candidates sort ascending, so an original narrower than a variant sorts between them.
227
- - **Discovery probes a fixed width list** (64, 96, 128, 160, 256, 320, 480, 640, 768, 960, 1024, 1280, 1440, 1536, 1920, 2048, 2560, 3840) through existence checks rather than a directory listing, which is what lets `compileFromMemory` resolve the convention from its in-memory map too. An off-list width like `x@500.webp` is a documented miss: never wired, never an error.
228
- - **`sizes` is never emitted.** The inline-attribute syntax on images carries only classes and ids, so there is no way to supply `sizes` in Markdown, and the compiler will not invent one. An author who needs `sizes` writes raw `<img srcset sizes>` on an `html: true` page.
229
- - **Never wired, by design:** a bound src (`![a]({ photo })` or a loop row's `![a]({ p.photo })`, which the runtime repaints and a build-time srcset would contradict), remote (`https://`, protocol-relative) and `data:` sources, srcs with a query/hash, page-relative or extensionless srcs, and raw-HTML `<img>` tags (author territory). With no variant present, the emitted tag is byte-identical to before the feature.
230
- - Variant files are ordinary colocated assets: they copy to `dist/` at their own path like any image, and an `@`-suffixed filename is never a route. Shelf images work too: variants beside `site/_/team/x.webp` resolve against the `/__wd/media/` public path.
231
- - The srcset composes with the load-priority split above: the first image stays eager with `fetchpriority="high"`, later variant-wired images get `loading="lazy"`, and author-set `loading`/`decoding`/`fetchpriority`/`width`/`height` are never overwritten.
232
- - **`.md` caveat:** variant wiring runs in the `.wd` compile path. A plain `.md` page still gets the width/height/lazy hardening but no srcset.
309
+ - **Only widths below the intrinsic size are generated (never upscale)**, and variants keep the source format: webp re-encodes at quality 82, jpeg at quality 82 progressive, png stays png. EXIF orientation is applied before resizing, so rotated phone photos come out upright. (The build is synchronous and sharp is not, so the resizes run in one child process per build; a fully cached build spawns nothing.)
310
+ - **Skipped silently:** images at or under 700px wide (already small everywhere), `name@<width>.ext` files themselves (output, never sources), hidden path segments (`.`, `-`, `_`) and symlinks (matching what the build publishes), and formats that are not resized (svg, gif, avif). The generator scans `site/pages` only; shelf images under `site/_` are not resized, though author-provided shelf variants still wire through the convention below.
311
+ - **Without sharp, the build behaves exactly as before plus one hint per build:** `hint: 5 images could gain responsive variants (site/pages/carousel/slide-1.jpg, ...). Run npm i -D sharp to generate them at build time.` A corrupt image (dimensions unreadable) or a sharp failure on one file prints a hint naming that file, and that image ships without variants; the build never fails because of an image.
312
+ - **Resizes are cached in `node_modules/.cache/darkmown-images/`**, keyed by content hash + width + format. A second build does zero sharp work for unchanged images and does not even load sharp when everything is cached (cached variants ship even if sharp is later removed). The cache lives under `node_modules`, which every project's `.gitignore` already excludes. Dev rebuilds recompute the variant map from the cache (no sharp spawn), so a partially rebuilt page is byte-identical to a full rebuild, srcset included; changing an image itself forces a full rebuild, which re-runs the pre-pass.
313
+ - The build summary reports the pre-pass when variants materialized: `Built 14 routes, sitemap (14 urls), rss (6 posts), image variants (2 generated, 5 cached) into dist`.
314
+
315
+ **Author-provided variants win (the `@width` convention).** Export `dr-aurora@640.jpg` yourself, same directory, same extension, and the build does not touch that width: your file is copied verbatim and measured from its own header (a `@640` file really holding 600px emits `600w`). Generation fills only the widths you did not provide, and the convention picks up any width on the compiler's fixed probe list (64, 96, 128, …, 3840), so other sizes are a hand export away; an off-list width like `x@500.webp` is a documented miss, never an error. Variant files (yours or generated) are ordinary colocated assets: they copy to `dist/` at their own path, and an `@`-suffixed filename is never a route. Everything else about the wiring is unchanged: descriptor widths are measured from file headers, candidates sort ascending with the original joining at its intrinsic width, `sizes` is never emitted (write raw `<img srcset sizes>` on an `html: true` page for that), and a page with no resizable images emits exactly what it did before.
316
+
317
+ **Never wired, by design:** a bound src (`![a]({ photo })` or a loop row's `![a]({ p.photo })`, which the runtime repaints), remote (`https://`, protocol-relative) and `data:` sources, srcs with a query/hash, page-relative or extensionless srcs, and raw-HTML `<img>` tags. **`.md` caveat:** variant wiring runs in the `.wd` compile path, so a plain `.md` page gets the width/height/lazy hardening but no srcset.
233
318
 
234
319
  **Or colocate an asset next to the page that uses it.** Any non-page file under `site/pages/` (anything but `.md`/`.wd` routes and the colocated `.skin`/`.js`) is copied to `dist/` at its own path, so a clean relative URL just works:
235
320
 
236
321
  - `site/pages/logo.svg` → `dist/logo.svg` → `![logo](/logo.svg)`
237
322
  - `site/pages/blog/cover.png` → `dist/blog/cover.png` → `![cover](/blog/cover.png)`
238
323
 
239
- Hidden/private page paths stay private: any `site/pages` asset below a path segment starting with `.`, `-`, or `_` is skipped, and symlinked page assets are never copied. Keep drafts, private notes, and local secrets out of public output by naming them like routes: `.env`, `_private/secret.txt`, or `-draft/notes.txt`.
324
+ Hidden/private page paths stay private: any `site/pages` asset below a path segment starting with `.`, `-`, or `_` is skipped, and symlinked page assets are never copied. Keep drafts, private notes, and local secrets out of public output by naming them like routes: `.env`, `_private/secret.txt`, or `-draft/notes.txt`. The shelf follows the same rule: `site/_/.env.backup` is never published, and a symlink inside `site/_/` is never copied to `dist` (it still works as an `@include`/`@loop` source, unrolling into pages at build time; it just no longer appears as a file at `/__wd/media/` or `/__wd/data/`).
240
325
 
241
326
  Use the shelf for assets shared across many pages; colocate the ones that belong to a single page or section. Both ship untouched with the correct content-type.
242
327
 
@@ -357,7 +442,7 @@ draft: true
357
442
 
358
443
  Drafts are filtered at one point — route discovery — so nothing downstream ever sees them. This is **separate from** the permanent `.`/`-`/`_` filename hiding: a hidden name (`-notes.wd`) is private forever; `draft:` is a toggle you flip when the page is ready to ship.
359
444
 
360
- The `build` summary reports what it emitted: `Built 14 routes, sitemap (14 urls), rss (6 posts) into dist`.
445
+ The `build` summary reports what it emitted: `Built 14 routes, sitemap (14 urls), rss (6 posts) into dist`, with an `image variants (2 generated, 5 cached)` clause appended whenever the image pre-pass produced or served cached responsive variants (see [Static assets](#static-assets)).
361
446
 
362
447
  ## Loops
363
448
 
@@ -1198,7 +1283,7 @@ Three one-line directives replace hand-written `<video>` / `<iframe>` markup. Th
1198
1283
  - **`:video` / `:audio`** compile to a hardened HTML5 player. `preload="metadata"` and `controls` are added by default; flags (`controls`, `autoplay`, `loop`, `muted`, `playsinline`) and attributes (`poster`, `width`, `height`, `preload`) are validated against a per-element whitelist, and `autoplay` silently implies `muted` (browsers block sound-on autoplay). The `src`/`poster` URLs run through the same scheme guard as `:fetch` — relative or `http(s)` only.
1199
1284
  - **`:embed`** rewrites a YouTube or Vimeo URL to its **no-cookie / player** form, wraps it in a responsive `16/9` box, and marks the iframe `loading="lazy"` with a locked-down `referrerpolicy`. Any other `http(s)` URL becomes a generic lazy iframe. Add `title="…"` for the accessible name. Three more attributes shape the frame (an embed with none of them emits byte-identical HTML to before): `aspect=4:3` swaps the ratio box for any `<w>:<h>` ratio, `height=400px` pins the frame height and drops the ratio box (units `px`, `rem`, `em`, `vh`, `vw`, `%`; `height` wins when both are given), and `allow="payment"` **replaces** the default iframe permission set with your own tokens from a fixed whitelist: `payment`, `autoplay`, `encrypted-media`, `fullscreen`, `picture-in-picture`, `clipboard-write`, `camera`, `microphone`, `geolocation` (`allow=""` strips all permissions). An unknown attribute is `[WD705]`, an unknown allow token `[WD706]`, and a malformed ratio or length `[WD707]`.
1200
1285
 
1201
- Darkmown's shipped CSP pre-authorizes exactly the two embed origins (`youtube-nocookie.com`, `player.vimeo.com`) and `media-src 'self' https:`, so embeds and remote media work out of the box on the bundled server, Cloudflare `_headers`, and Vercel.
1286
+ Darkmown's shipped CSP pre-authorizes the two built-in embed origins (`youtube-nocookie.com`, `player.vimeo.com`) on every page, and **unions any other `:embed` host into `frame-src` for exactly the pages that embed it** (a same-origin embed records `'self'`): a NexHealth-style booking iframe works under the shipped headers with no hand-edited policy. The per-route union is emitted in Cloudflare's `dist/_headers` and honored by `darkmown serve`; a page with no extra embed hosts keeps a byte-identical CSP. `media-src 'self' https:` is unchanged, so remote media works out of the box. **Vercel caveat:** Vercel reads `vercel.json` before the build runs, so the generated default carries only the base policy; a project embedding a non-YouTube/Vimeo host on Vercel widens `frame-src` in its own `vercel.json`, the same pattern as `connect-src` for remote `:fetch` targets.
1202
1287
 
1203
1288
  ## Syntax highlighting
1204
1289
 
@@ -1338,6 +1423,22 @@ A `.skin` already wraps nested rules in `@media` and `@supports`. Two more CSS a
1338
1423
 
1339
1424
  Since 2.8 the body parser also tracks raw `<style>` and `<script>` blocks on `html: true` pages, so `@font-face {` or `@media … {` lines inside a raw `<style>` no longer trip the "looks like a directive" hint on every line; the hint still fires for directive-shaped lines in ordinary prose and other raw HTML.
1340
1425
 
1426
+ ### Build-time font subsetting
1427
+
1428
+ Declaring `@font-face` against a project-local `.ttf`/`.otf`/`.woff` gets you the font; with the wasm toolchain installed, the build also shrinks it. After every page and stylesheet is emitted, a post-pass scans `dist/**/*.css` for `@font-face` blocks, subsets each project-local font `src url(...)` to the site's character set, encodes it as woff2, emits `<name>-subset.woff2` beside the original, and rewrites the dist CSS so `src:` lists the subset first with the original as fallback. The source `.skin` is never touched, and every other descriptor (`font-display`, `font-weight` ranges, …) passes through byte-identically. Layout features (kern, liga, calt) and variable-font axes survive subsetting.
1429
+
1430
+ ```sh
1431
+ npm i -D harfbuzzjs wawoff2
1432
+ ```
1433
+
1434
+ Both are optional dependencies, lazily loaded in a child process behind the same seam as the image-variant sharp integration (the build is synchronous; the wasm toolchain is not, so one `spawnSync` worker per build does the work, and a fully cached build spawns nothing).
1435
+
1436
+ - **The charset is the union of every code point in the emitted HTML** (numeric character references like `&#9733;` decoded first) plus a base set: printable ASCII (U+0020–U+007E), the Latin-1 supplement (U+00A0–U+00FF), punctuation U+2010–U+2027 (hyphens, dashes, curly quotes, ellipsis, bullets), primes and per-mille (U+2030–U+2033), single guillemets (U+2039–U+203A), the arrows ← ↑ → ↓ and diagonals (U+2190–U+2199), and ★ ☆ ✓ ✕ ✗. The base set exists so text a colocated `.js` injects at runtime is still covered; non-latin scripts flow in through the page-text union. Known boundary: glyphs used only in CSS `content` are not collected, and the original font stays listed as the fallback, so nothing breaks visually.
1437
+ - **Without the toolchain** the build is unchanged plus one hint per build: `hint: 2 fonts could be subsetted to woff2 (site/_/fonts/a.ttf, site/_/fonts/b.ttf). Run npm i -D harfbuzzjs wawoff2 to subset fonts at build time.` A font that fails to parse or subset gets its own hint and ships as-is; the build never fails over a font.
1438
+ - **Skips, all silent:** remote, protocol-relative, `data:`, and relative `src` URLs; already-`.woff2` sources; paths that escape the project; missing files.
1439
+ - **Cache:** `node_modules/.cache/darkmown-fonts/<fontHash>-<charsetHash>.woff2`, so a second build with unchanged fonts and content does zero wasm work and needs no toolchain for cached entries. The CSS rewrite is idempotent, which is what makes incremental dev builds safe.
1440
+ - The build summary gains a `fonts (3 subsetted, 0 cached)` clause when the post-pass did work. Measured on a 67-route marketing rebuild: three variable TTFs totaling 1.07 MB became 288 KB of subset woff2 (a 3.7x reduction), first build about 9.6 s, cached rebuilds about 1.0 s.
1441
+
1341
1442
  ## The escape hatch
1342
1443
 
1343
1444
  Reactive pages expose `window.wd` so a colocated `.js` file can do anything the directives deliberately don't — keyboard, drag/touch, canvas, charts, maps:
@@ -1549,7 +1650,7 @@ Darkmown is small on purpose. Here is what it deliberately refuses and what it g
1549
1650
  - **Reactive rows cannot fill a Markdown table.** A reactive row is cloned into a `<div>`, which is not a legal child of `<table>`, so `[WD191]` refuses it. Static loops fill tables fine; for reactive rows, build them from containers (`::: trow` / `::: td`) and style them as a table.
1550
1651
  - **No i18n beyond `lang:`.** No `hreflang`, no message catalog, no ambient locale, no locale routing. A multilingual site means duplicating the tree per locale.
1551
1652
  - **Every framework asset URL is absolute** (`/__wd/...`), so a Darkmown site cannot be mounted at a subpath. It is all-or-nothing per origin.
1552
- - **Includes are macros, not components.** No children, no slots, no default arguments, and a missing argument renders `{ title }` literally rather than erroring.
1653
+ - **Includes are macros, not components.** One slot (`{ children }`), string-typed `defaults:` fallbacks, and no props interface beyond `with`; a missing argument with no default still renders `{ title }` literally rather than erroring.
1553
1654
  - **No `darkmown test`.** There is no built-in way to assert your own site's behavior. Use [`compileFromMemory`](#programmatic-compile--compilefrommemory) against your own pages, or a browser test runner.
1554
1655
 
1555
1656
  **Migrating out is easy, and that is on purpose.** `dist/` is portable static HTML plus one runtime file of ~6.6 KB gzipped. There is no server to port, no proprietary component format, no framework runtime baked through the output, and nothing to un-eject. Point any host at the folder, or hand it to whatever you move to next, and the site keeps working. The lock-in is a directory of Markdown files you already own.
@@ -2,6 +2,16 @@
2
2
 
3
3
  This audit compares the current Darkmown implementation with the original MARROW/WhateverDown vision and the follow-up decisions made on 2026-06-11.
4
4
 
5
+ ## 2.10.0 additions: compile-time components, subset fonts, hardening (2026-10-14)
6
+
7
+ - **Includes became components without leaving the macro model.** `defaults:` frontmatter gives an include's `with` arguments string fallbacks (`WD613` on a malformed array), and the `@include … @endinclude` block form compiles the enclosed lines once in the caller's scope and inlines them at the include's standalone `{ children }` line: the card/banner wrapper the vision always implied, at zero runtime cost. The pairing decision is deliberate and documented: `@endinclude` closes the nearest unpaired opener (an inline include in between consumes it), one level of block nesting is supported (`WD616` deeper), a stray closer is `WD614`, and there is NO unclosed-block error, because an unpaired opener is the inline form and every pre-feature page relies on it. `WD615` stays unassigned for a future unclosed-detection rule that avoids those false positives.
8
+ - **Fonts subset at build time as a dist-CSS post-pass, not a `.skin` feature.** The builder scans the emitted CSS for `@font-face` srcs, subsets project-local fonts with harfbuzz's wasm and encodes woff2 with wawoff2 (both optionalDependencies, run in a `spawnSync` worker because the build is synchronous and the toolchain is not), and rewrites the dist `src:` to list the subset first while the source `.skin` stays untouched. The charset is the union of every emitted HTML code point plus a base punctuation/symbol set, so runtime-injected text stays covered. Without the toolchain the build is 2.9.0-identical plus one hint. Measured 3.7x on the rebuild's three variable fonts.
9
+ - **Security hardening closed four publisher gaps, and CSP now follows the embed.** Shelf assets no longer publish hidden segments or symlinks (an in-sandbox symlink still works as an `@include`/`@loop` source; it is just never copied), NUL-byte URLs answer 404 instead of killing the preview server, the generated Cloudflare worker returns a generic 500 while logging the real error server-side, and compile-time image measurement enforces the `@include` sandbox. The user-visible one: `frame-src` unions each route's `:embed` hosts, so a third-party booking or checkout iframe works under the shipped headers on Cloudflare and `darkmown serve` (Vercel needs a `vercel.json` widening, because Vercel reads it before the build).
10
+
11
+ ## 2.9.0 additions: build-time image variants (2026-10-13)
12
+
13
+ - **The framework now makes the image variants itself, closing the rebuild's largest asset gap.** With the optional sharp dependency installed, `darkmown build` resizes page-colocated raster photos over 700px into 640/1280 `name@width.ext` siblings (never upscaling, EXIF-aware, cached by content hash under `node_modules/.cache/darkmown-images/` so rebuilds and dev reloads do no sharp work), the 2.8.0 author-provided `@width` convention stays the per-width override, and a build without sharp behaves exactly like 2.8.0 plus one hint.
14
+
5
15
  ## 2.8.0 additions: the client-site rebuild release (2026-10-13)
6
16
 
7
17
  A 67-route marketing site rebuilt in Darkmown produced a friction report, and this release is the report answered: every item either became a feature, a coded error, or a documented recipe. Nothing in it moves the core thesis; it rounds out the authoring surface a real design system needs while keeping static pages zero-JS.
@@ -3,7 +3,7 @@
3
3
 
4
4
  root ::= directive | closer | binding
5
5
  directive ::= dir-include | dir-loop | dir-container | dir-if | dir-state | dir-store | dir-computed | dir-fetch | dir-effect | dir-every | dir-theme | dir-button | dir-form | dir-input | dir-textarea | dir-select | dir-checkbox | dir-radio | dir-submit | dir-bind | dir-slider | dir-video | dir-audio | dir-embed | dir-carousel
6
- closer ::= "@endloop" | "@empty" | ":endif" | ":endform" | ":endcarousel" | ":::" | ":else if" sp condition ( sp joiner sp condition )* | ":else if" sp path | ":else"
6
+ closer ::= "@endloop" | "@empty" | "@endinclude" | ":endif" | ":endform" | ":endcarousel" | ":::" | ":else if" sp condition ( sp joiner sp condition )* | ":else if" sp path | ":else"
7
7
 
8
8
  dir-loop ::= "@loop" sp operand sp "into" sp ident loop-clauses
9
9
  dir-if ::= ":if" sp ( condition (sp joiner sp condition)* | path )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -86,9 +86,12 @@
86
86
  "@playwright/test": "^1.61.1",
87
87
  "@types/node": "^26.1.1",
88
88
  "esbuild": "0.28.2",
89
+ "harfbuzzjs": "^1.6.3",
89
90
  "husky": "^9.1.7",
90
91
  "lint-staged": "^17.0.0",
91
- "typescript": "^6.0.3"
92
+ "sharp": "^0.35.5",
93
+ "typescript": "^6.0.3",
94
+ "wawoff2": "^2.0.1"
92
95
  },
93
96
  "repository": {
94
97
  "type": "git",
@@ -107,5 +110,10 @@
107
110
  "zero-js",
108
111
  "reactive"
109
112
  ],
110
- "license": "MIT"
113
+ "license": "MIT",
114
+ "optionalDependencies": {
115
+ "harfbuzzjs": "^1.6.3",
116
+ "sharp": "^0.35.5",
117
+ "wawoff2": "^2.0.1"
118
+ }
111
119
  }
@@ -0,0 +1,67 @@
1
+ /* The benchmarks page: measured numbers from rebuilding a production
2
+ Next.js marketing site in Darkmown. Layers on /base.wd. Class names
3
+ match the `::: name .class` containers in index.wd. The page contains
4
+ no directives and no raw HTML, so it ships zero JavaScript. */
5
+
6
+ .stats
7
+ display grid
8
+ grid-template-columns repeat(4, 1fr)
9
+ gap 1rem
10
+ margin 1.75rem 0 2.25rem
11
+ padding 1.5rem 0
12
+ border-top 1px solid $line-soft
13
+ border-bottom 1px solid $line-soft
14
+
15
+ /* The value line and its label must stay separate containers, otherwise
16
+ Markdown folds them into one paragraph (same trick as the homepage). */
17
+ .stat
18
+ display flex
19
+ flex-direction column
20
+ gap .25rem
21
+ min-width 0
22
+
23
+ .stat > p
24
+ margin 0
25
+ font-size 2rem
26
+ line-height 1.05
27
+ color $accent
28
+ letter-spacing -.02em
29
+ font-variant-numeric tabular-nums
30
+
31
+ .statlabel
32
+ font-size .85rem
33
+ line-height 1.4
34
+ color $ink-soft
35
+
36
+ .statlabel p
37
+ margin 0
38
+
39
+ /* The route-by-route table is wide; let it scroll sideways on a phone
40
+ instead of crushing the columns. */
41
+ .tablewrap
42
+ overflow-x auto
43
+ margin 1.5rem 0
44
+
45
+ .tablewrap table
46
+ margin 0
47
+ min-width 720px
48
+ font-size .88rem
49
+
50
+ .tablewrap th, .tablewrap td
51
+ white-space nowrap
52
+
53
+ /* Numeric columns read better right-aligned and tabular. */
54
+ .tablewrap th:nth-child(n+2), .tablewrap td:nth-child(n+2)
55
+ text-align right
56
+ font-variant-numeric tabular-nums
57
+
58
+ .bench-cta
59
+ display flex
60
+ flex-wrap wrap
61
+ gap .6rem
62
+ margin 2.5rem 0 0
63
+
64
+ @media (max-width: 760px)
65
+ .stats
66
+ grid-template-columns 1fr 1fr
67
+ gap 1.25rem