@zvndev/darkmown 2.9.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
@@ -42,7 +42,7 @@ package.json
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
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>`.
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,19 @@
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
+
5
18
  ## 2.9.0 (2026-10-13)
6
19
 
7
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.
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 }`.
@@ -238,7 +321,7 @@ Reference them with a normal URL: `![logo](/__wd/media/logo.svg)`. The preview/`
238
321
  - `site/pages/logo.svg` → `dist/logo.svg` → `![logo](/logo.svg)`
239
322
  - `site/pages/blog/cover.png` → `dist/blog/cover.png` → `![cover](/blog/cover.png)`
240
323
 
241
- 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/`).
242
325
 
243
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.
244
327
 
@@ -1200,7 +1283,7 @@ Three one-line directives replace hand-written `<video>` / `<iframe>` markup. Th
1200
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.
1201
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]`.
1202
1285
 
1203
- 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.
1204
1287
 
1205
1288
  ## Syntax highlighting
1206
1289
 
@@ -1340,6 +1423,22 @@ A `.skin` already wraps nested rules in `@media` and `@supports`. Two more CSS a
1340
1423
 
1341
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.
1342
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
+
1343
1442
  ## The escape hatch
1344
1443
 
1345
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:
@@ -1551,7 +1650,7 @@ Darkmown is small on purpose. Here is what it deliberately refuses and what it g
1551
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.
1552
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.
1553
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.
1554
- - **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.
1555
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.
1556
1655
 
1557
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,7 +2,13 @@
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.9.0 additions (unreleased, 2026-10-13)
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)
6
12
 
7
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.
8
14
 
@@ -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.9.0",
3
+ "version": "2.10.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -86,10 +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
92
  "sharp": "^0.35.5",
92
- "typescript": "^6.0.3"
93
+ "typescript": "^6.0.3",
94
+ "wawoff2": "^2.0.1"
93
95
  },
94
96
  "repository": {
95
97
  "type": "git",
@@ -110,6 +112,8 @@
110
112
  ],
111
113
  "license": "MIT",
112
114
  "optionalDependencies": {
113
- "sharp": "^0.35.5"
115
+ "harfbuzzjs": "^1.6.3",
116
+ "sharp": "^0.35.5",
117
+ "wawoff2": "^2.0.1"
114
118
  }
115
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
@@ -0,0 +1,126 @@
1
+ ---
2
+ title: Benchmarks: a production Next.js site, rebuilt in Markdown
3
+ description: We rebuilt a live production marketing site (Next.js 16, React 19, Tailwind, Vercel) in Darkmown and measured both sides. Same copy, same images, same CMS. A tenth of the JavaScript, a third of the requests, and the two places the rebuild lost, stated plainly.
4
+ image: https://darkmown.com/__wd/media/og.png
5
+ site_url: https://darkmown.com
6
+ transitions: true
7
+ schema: TechArticle
8
+ ---
9
+
10
+ @include /nav.wd
11
+ @include /base.wd
12
+
13
+ ::: main
14
+
15
+ ::: eyebrow
16
+ Benchmarks
17
+ :::
18
+
19
+ # Same site. Same copy. A tenth of the JavaScript.
20
+
21
+ We took a live production marketing site, a multi-location dental practice running **Next.js 16, React 19, and Tailwind on Vercel**, and rebuilt it in Darkmown: same copy, same images, same fonts, same CMS content pipeline. Then we measured both. This page is every number we found, including the two categories where the rebuild is worse.
22
+
23
+ The rebuild lives in a private demo repository, so there is no source link; every figure below is the median of three measured page loads, and the [methodology](#methodology) section says exactly how we measured.
24
+
25
+ ## The headline numbers
26
+
27
+ ::: stats
28
+
29
+ ::: stat
30
+ 10x
31
+ ::: statlabel
32
+ less JavaScript on the homepage: 22 KB against 225 KB, raw bytes against compressed. Gzip to gzip it is 28x.
33
+ :::
34
+ :::
35
+
36
+ ::: stat
37
+ 3.3x
38
+ ::: statlabel
39
+ fewer requests per page: 11 to 20 across the four measured routes, against 46 to 70.
40
+ :::
41
+ :::
42
+
43
+ ::: stat
44
+ 5.6x
45
+ ::: statlabel
46
+ smaller HTML document: 56.7 KB raw against 318 KB. Compressed both ways, still 2.4x leaner.
47
+ :::
48
+ :::
49
+
50
+ ::: stat
51
+ ~6x
52
+ ::: statlabel
53
+ faster full builds: 2.7 to 3.6 seconds for 67 routes, against 16.6 s cold and 23.2 s warm.
54
+ :::
55
+ :::
56
+
57
+ :::
58
+
59
+ ## What every page pays
60
+
61
+ | | Next.js on Vercel | Darkmown rebuild |
62
+ |---|---|---|
63
+ | JavaScript per page | 225 to 227 KB compressed, 16 chunks | 18 to 23 KB total (the whole runtime is 6.6 KB gzipped) |
64
+ | Requests per page | 46 to 70 | 11 to 20 |
65
+ | Homepage HTML document | 318 KB raw, 24.9 KB brotli | 56.7 KB raw, 10.6 KB gzip |
66
+ | Third-party JavaScript | ~357 KB (Google Tag Manager, DoubleClick) | 0 B |
67
+
68
+ Two things worth saying out loud. The live site sends the **same 16 chunks, byte-identical, on all four measured routes**: whatever route-level code splitting Next.js is doing never showed up, so every page pays the full 226 KB. And the Darkmown column is raw bytes off an uncompressed preview server while the Next.js column is compressed transfer, so the real gap is wider than the table shows.
69
+
70
+ ## Route by route
71
+
72
+ ::: tablewrap
73
+
74
+ | Route | JS: Next.js | JS: Darkmown | Requests: Next.js | Requests: Darkmown | Total wire: Next.js | Total wire: Darkmown |
75
+ |---|---|---|---|---|---|---|
76
+ | `/` | 225 KB | 22 KB | 50 | 15 | 2.16 MB | 1.79 MB |
77
+ | `/services/dental-implants/` | 226 KB | 18 KB | 54 | 12 | 828 KB | 793 KB |
78
+ | `/doctors/` | 227 KB | 18 KB | 70 | 20 | 965 KB | **1.69 MB** |
79
+ | `/request-appointment/` | 226 KB | 23 KB | 46 | 11 | 784 KB | 678 KB |
80
+
81
+ :::
82
+
83
+ The doctors route is the one page where the rebuild's total wire bytes are **higher**, and it is not a rounding error: the rebuild shipped unoptimized headshots where the live site serves CDN width variants. That is the next section.
84
+
85
+ ## Build time and source size
86
+
87
+ Measured on the same machine, 67 routes on both sides:
88
+
89
+ | | Next.js 16 | Darkmown |
90
+ |---|---|---|
91
+ | Cold build | 16.6 s | 2.7 to 3.6 s |
92
+ | Warm build | 23.2 s | 2.7 to 3.6 s (there is no build cache) |
93
+ | CPU time per build | ~30 s | ~0.6 s |
94
+
95
+ | | Next.js project | Darkmown rebuild |
96
+ |---|---|---|
97
+ | Maintained source | ~33,000 lines (24.5k code + 8.4k CMS JSON) | 17,300 lines total |
98
+ | Project on disk | 39 MB | 5.5 MB |
99
+ | `node_modules` | 932 MB | 15 MB (62x smaller) |
100
+
101
+ ## Where Darkmown loses
102
+
103
+ Both of these were real losses on measured bytes, not hypotheticals, and both shaped what shipped next.
104
+
105
+ - **Fonts, badly.** The rebuild served three raw variable TTFs: 1.07 MB on the homepage, against `next/font`'s 158 KB of subset woff2. Fonts were the single largest byte category on every rebuilt page. The 2.8 documentation now ships a font recipe (convert to woff2, subset to the glyphs used, `font-display swap`, preload the critical face), and build-time subsetting is on the roadmap.
106
+ - **Photos.** The doctors page shipped 1,045 KB of headshots against the live site's 147 KB of CDN width variants. Darkmown 2.8 added the `@width` convention (drop in `photo@640.jpg` and the build wires the `srcset`), and 2.9 generates those variants at build time when sharp is installed. Run the same rebuild today and this category flips.
107
+ - **A shared cost neither side avoids:** both sites load the same ~594 KB hero video on the homepage. No edge for anyone there.
108
+
109
+ ## Methodology
110
+
111
+ - Measured on 2026-10-13 with Chromium via Playwright: 1280x900 desktop viewport, cache disabled, three fresh loads per URL, median reported. Wire bytes are CDP `encodedDataLength`, which is compressed transfer size plus response headers.
112
+ - The live site runs on Vercel's edge network; the rebuild was measured from a local static build (`darkmown serve`). Local timings reflect zero network latency, so **we do not compare timing columns at all**. Bytes and request counts are the fair comparison.
113
+ - The preview server did not compress responses in this run, so the rebuild's text wire bytes overstate what the same `dist/` transfers behind a compressing host: its homepage HTML is 57.4 KB on the wire but 10.6 KB gzipped, and any Vercel or Cloudflare deploy of the same output gets brotli and edge caching for free.
114
+ - Four routes measured out of 67. The content pipeline is identical on both sides: the same Capa CMS snapshot drives both sites, and the Darkmown sync regenerates its 41 managed pages byte-identically in 0.05 s.
115
+ - **What Next.js does that Darkmown does not:** server rendering, streaming, per-request personalization, edge caching, an image CDN, and automatic font subsetting. This site exercises none of the dynamic ones; its pages are the same for every visitor, which is exactly the case a static build exists for. Where Next.js's automatic asset optimization (fonts, responsive images) would win, it did win, and that is the section above.
116
+ - Whether a production dental site keeps Google Tag Manager is a business decision, not a framework property. The rebuild demonstrates the site works without it, and the ~357 KB saving is stated as such.
117
+
118
+ ::: bench-cta
119
+ [Read the docs](/docs/){.btn .primary}
120
+ [Try Darkmown in your browser](/playground/){.btn}
121
+ [Darkmown vs. Markdoc](/vs/markdoc/){.btn}
122
+ :::
123
+
124
+ :::
125
+
126
+ @include /footer.wd