@zvndev/darkmown 2.10.1 → 2.12.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,10 +41,10 @@ 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:** 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.
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; the build never invents a `sizes` value, but the author can set one on the Markdown image with `![hero](hero.jpg){sizes="(max-width: 640px) 100vw, 640px"}` (raw `<img srcset sizes>` on an `html: true` page remains the escape hatch). `.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
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
- 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]`.
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]`, `darkmown mcp` (an MCP server on stdio: catalog, grammar, validate, error codes, docs).
48
48
 
49
49
  ## Frontmatter: the complete key set
50
50
 
@@ -73,7 +73,7 @@ rss_limit: 20
73
73
  ```
74
74
 
75
75
  - `title`, `description`, `image` drive `<title>`, the meta description, and the Open Graph / Twitter card. `lang` sets `<html lang>` (default `en`).
76
- - `html: true` allows raw HTML in that file's body. `transitions: true` opts into view transitions plus link prerendering.
76
+ - `html: true` allows raw HTML in that file's body. `transitions: true` opts into an opacity-only cross-fade view transition plus link prerendering.
77
77
  - `draft: true` keeps the page out of `dist`, `routes.json`, the sitemap, and the feed. `darkmown dev` still serves it; `darkmown build --drafts` includes it.
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.
@@ -81,7 +81,7 @@ rss_limit: 20
81
81
  - `defaults: [name="value"]` (include files only) sets fallback values for `with` arguments; see Includes & sections.
82
82
  - `date` marks the page a post (RSS item, sitemap `lastmod`, `og:type=article`). `updated` and `excerpt` refine that. `author` feeds an article `schema:`.
83
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.
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).
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). `md_source: true` publishes every page's byte-exact markdown source at `<route>.md` (with a `<link rel="alternate" type="text/markdown">` head tag); a page opts out with `md_source: false`.
85
85
  - The page's own frontmatter is in scope as `meta`. Anything else you write is a free-form field, queryable when the page is a collection entry.
86
86
 
87
87
  ## Interpolation — one syntax
@@ -508,7 +508,7 @@ Your `.skin` selectors must match the **real** output. The emitted HTML is:
508
508
  | reactive `@loop` of a list | `<ul>`/`<ol>` (or `<div>`) of items |
509
509
  | `{ name }` | a text node |
510
510
 
511
- So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class`; to add one to a single inline element, use a trailing `{.class .class #id}`, as in `[Get started](/start/){.btn}`.** Inline attributes attach to **links, images, and emphasis** only; they do **not** work on inline code (`` `x` ``{.hl} renders the braces as text). A container class can also be reactive: `::: card .on-sale when p.price < 50` toggles the class from a predicate (same grammar as `:if`).
511
+ So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class`; to add one to a single inline element, use a trailing `{.class .class #id}`, as in `[Get started](/start/){.btn}`.** Inline attributes attach to **links, images, and emphasis** only; they do **not** work on inline code (`` `x` ``{.hl} renders the braces as text). On an image the block also takes `sizes="…"` (`![hero](hero.jpg){sizes="(max-width: 640px) 100vw, 640px"}`), passed through HTML-escaped with or without a wired `srcset`. A container class can also be reactive: `::: card .on-sale when p.price < 50` toggles the class from a predicate (same grammar as `:if`).
512
512
 
513
513
  Every heading also gets a stable GitHub-style `id` at build time, so `[jump](#my-heading)` deep-links with zero JS.
514
514
 
@@ -676,7 +676,7 @@ Raw TTFs are the most expensive asset a marketing site ships: in the measured re
676
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
677
  2. Or do it by hand: convert to **woff2**, subset to the glyphs the site uses, and ship the result yourself.
678
678
 
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.
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, and always set `font-display swap`. Preloading is automatic since 2.12: the build injects one `<link rel="preload" as="font" crossorigin>` per project font into every page whose own stylesheets declare it, leading with the subset woff2 when subsetting ran, so the first paint skips the fallback-font swap and reflow.
680
680
 
681
681
  ### Pre-fill a form message from query params
682
682
 
@@ -714,6 +714,8 @@ Codes are grouped by subsystem: `WD0xx` source and frontmatter, `WD1xx` loops an
714
714
 
715
715
  The ones you are most likely to hit while writing new pages: `WD191` (a reactive `@loop` over `|` table rows), `WD250` (a reserved declaration name), `WD251` (`:state b = a` where `a` is state), `WD260` (`from-url` on `:store`/`:theme`), `WD261` (`persist` and `ephemeral` together), `WD315` (`:every`/`:effect` inside a reactive loop), `WD450` (a bound field with no state), `WD451` (a bound `:checkbox` with several options), `WD452` (a file field in a GET form), `WD650`/`WD651` (a non-whitelisted or unquoted attribute), `WD804`-`WD807` (a malformed `@font-face` or `@keyframes` block: a stray name on `@font-face`, a missing or invalid `@keyframes` name, a non-keyframe child, or a keyframe with no declarations), `WD950` (a bad `rss_limit`). Every `Use:` hint that contains `[bracket]` placeholders ends with a concrete `— e.g. <valid line>`: **copy the example, never the placeholder.** `darkmown catalog --llms-full` prints every code with its cause and fix.
716
716
 
717
+ **MCP server.** `darkmown mcp` speaks the Model Context Protocol on stdio (protocol 2024-11-05, hand-rolled, zero-dependency; stdout is protocol frames only, logs go to stderr). Five tools: `catalog`, `grammar`, `validate` (compile-check `.wd` source with coded errors), `error` (a WD code's cause, fix, and example), and `doc` (fuzzy section search over the shipped README/AGENTS.md), plus `darkmown://docs/{readme,agents,changelog,errors,cli}` resources. Point a client at it with `npx -y @zvndev/darkmown mcp`.
718
+
717
719
  ## Hard rules — do not break these
718
720
 
719
721
  1. **Never invent directives or syntax.** These do NOT exist: `@section`/`@endsection`, `{% if %}`, `:for`, `@repeat`, `v-if`, JSX, or any directive not in the list above. The only container is `::: … :::`; the only loop is `@loop`; the only interpolation is `{ }`.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
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.12.0 (2026-10-16)
6
+
7
+ A perceived-performance release from client-demo feedback (fonts visibly swapped on every reload, and the navigation transition read as a flicker): fonts now preload, the preview server revalidates instead of re-downloading, the default view transition is a plain cross-fade, and Markdown images can carry an author-set `sizes`. No breaking change; the transition animation change is called out below.
8
+
9
+ - **Build-time font preloads.** The font post-pass gained a stage: after subsetting and the CSS rewrite, it injects one `<link rel="preload" as="font" crossorigin>` into every emitted page for each project font the page's own stylesheets declare, placed right after the last stylesheet link. The preloaded URL is exactly the one the rewritten CSS leads with (the `-subset.woff2` when subsetting ran, else the original project font, which still beats no preload), with `type` matching the extension and `crossorigin` always set, because font fetches are always CORS-mode and a preload without it downloads twice. Only fonts the page actually loads are preloaded (a page whose CSS declares no project fonts is byte-identical), remote `http(s)` srcs are never preloaded, already-`.woff2` project fonts are preloaded as-is, and the injection is idempotent, so incremental dev rebuilds never duplicate a link. The payoff is no fallback-font swap and reflow on first paint.
10
+ - **Conditional-request caching on `darkmown serve`.** Every static response (200s and the built 404) now sends `Last-Modified` and a strong `ETag`, and `If-None-Match` / `If-Modified-Since` are honored with body-less 304s (comma-lists and `*`, RFC 7232 precedence, validators repeated on the 304). Cache-Control by file class: HTML gets `no-cache` (always revalidate, cheaply), `/__wd/` assets and media/font extensions get `public, max-age=3600` (unchanged between builds, and any rebuild moves the validators), everything else stays deliberately unopinionated at `no-cache`. The freshness check runs before compression work and composes with the existing br/gzip negotiation; `api/` responses are untouched, and `darkmown dev` sends none of this by design. Reloads in the preview now revalidate instead of re-downloading every asset.
11
+ - **BEHAVIOR CHANGE: the default view transition is now a simultaneous cross-fade.** `transitions: true` used to emit paired directional animations (the old page slid up and out, the new one rose in), which on a real site read as a flicker: the outgoing page appeared to come back mid-navigation. Both animations are now opacity-only, 160ms with one shared easing both ways, so out and in run as one cross-fade rather than an out-then-in sequence. There is deliberately no `transform` anywhere in the stylesheet. The keyframe names (`wd-nav-out` / `wd-nav-in`) are unchanged, so author CSS referencing them still resolves; the `@view-transition` opt-in line, the reduced-motion reduction (1ms), and the speculation-rules prerender are unchanged; and a `transitions: true` page still ships zero JS.
12
+ - **Author-set `sizes` on Markdown images.** The inline-attribute block now accepts `sizes="…"` on images, in any order alongside `.class` and `#id`: `![hero](hero.jpg){sizes="(max-width: 640px) 100vw, 640px"}` passes the attribute through, HTML-escaped, whether or not the image has a wired `srcset` (with variants it rides alongside them). Without `sizes`, browsers default to `100vw` and fetch the full-size original on desktop, which made the 2.9 variant wiring dead weight there. The "the build never invents a sizes value" rule stands; on a link or emphasis the token is ignored; unrecognized blocks like `{foo="x"}` still render as literal text; and raw-HTML `<img>` remains author territory.
13
+
14
+ ## 2.11.0 (2026-10-15)
15
+
16
+ A release for the agents reading the docs, not just the agents writing pages: the compiler and the whole language description are now one MCP server away, and any page can serve its own markdown source. No breaking change.
17
+
18
+ - **`darkmown mcp`: a zero-dependency MCP server on stdio.** MCP over stdio is newline-delimited JSON-RPC 2.0 (protocol revision 2024-11-05), so the server is hand-rolled on Node built-ins rather than the SDK: `dependencies` stays empty, and there is no SDK version to drift against. stdout carries protocol frames only; everything else goes to stderr. Five tools, each a thin adapter over a surface the CLI and the test suite already pin, so an MCP client and the CLI can never disagree: `catalog` (the directive catalog as `json`, `llms`, or `llms-full`), `grammar` (the full GBNF grammar or a cheatsheet slice), `validate` (compile `.wd`/`.md` source in memory; success answers static/reactive status and warnings, failure answers the WD code, file:line, `Use:` hint, and example), `error` (one code's cause, fix, and example, or the valid list), and `doc` (fuzzy section search over the shipped README and AGENTS.md). Five resources expose the shipped docs at `darkmown://docs/{readme,agents,changelog,errors,cli}`, read from the installed package at request time. Prompts, logging, completion, sampling, subscriptions, and batched requests are deliberately unimplemented (a JSON array gets one Invalid Request error, which the spec permits). Client config: `{ "mcpServers": { "darkmown": { "command": "npx", "args": ["-y", "@zvndev/darkmown", "mcp"] } } }`. As part of the MCP work, **`docs/errors.md` now ships in the published tarball** (it was repo-only before), so the `darkmown://docs/errors` resource works from an installed package.
19
+ - **Per-route markdown sources: `md_source:`.** `md_source: true` on the HOME page opts the whole site in (off by default; a site that never sets it builds byte-identical output). Every route then serves its byte-exact source file one level up from its HTML: `/` answers `/index.md`, `/docs/` answers `/docs.md`, with `text/markdown; charset=utf-8` (a compressible text type, so `darkmown serve` negotiates br/gzip on it), frontmatter and CRLF endings included. Each published page carries `<link rel="alternate" type="text/markdown" href="/docs.md" title="Markdown source">`, emitted exactly when the file is, so it never 404s. A page opts back out with `md_source: false`; a page-level `true` without the home flag does nothing; drafts never emit a source; sitemap, RSS, and robots never list the `.md` files (they are alternates, not routes); paginated listings publish under every generated page (`/news.md` and `/news/page/2.md`). Dev handles the edges: flipping the home flag forces a full rebuild, editing a page refreshes its copy, opting a page out withdraws its copy, and a shrunk pagination removes stale `/page/N.md` files.
20
+
5
21
  ## 2.10.1 (2026-10-14)
6
22
 
7
23
  A patch release of hardening and contract repair: a security pass over everything the build publishes and serves, the skin at-rule errors gaining their registry codes, a compiler hint for secret-shaped state seeds, and a polish sweep over the playground, templates, and VS Code extension. Behavior tightening an author could notice is called out per item.
package/README.md CHANGED
@@ -84,8 +84,9 @@ npm run dev # live demo site — the same site that runs darkmown.com
84
84
  - `darkmown build [--target cloudflare] [--drafts]` writes static output to `dist` (plus `sitemap.xml`/`rss.xml`/`robots.txt`); `--drafts` includes `draft: true` pages for staging.
85
85
  - `darkmown validate [path]` compile-checks `site/pages` (or one file or directory) against the real compiler without writing `dist`. Each page reports `compiles.` with its static/reactive status, warnings as bullets, or the coded error with its file, line, and fix hint; drafts are included and the exit code is 1 when any page fails, so it gates CI.
86
86
  - `darkmown deploy <vercel|cloudflare> [--prod]` builds and deploys via the platform CLI.
87
- - `darkmown serve` previews the built `dist` locally. It also runs the project's `api/` functions (same handler contract and routing as `darkmown dev` and the production hosts, so `:form action="/api/…"` posts and `:fetch … from "/api/…"` demos work in the preview), and it compresses text responses: brotli preferred, then gzip, negotiated off the request's `Accept-Encoding` with `Vary: Accept-Encoding` set. `darkmown dev` is deliberately uncompressed.
87
+ - `darkmown serve` previews the built `dist` locally. It also runs the project's `api/` functions (same handler contract and routing as `darkmown dev` and the production hosts, so `:form action="/api/…"` posts and `:fetch … from "/api/…"` demos work in the preview), and it compresses text responses: brotli preferred, then gzip, negotiated off the request's `Accept-Encoding` with `Vary: Accept-Encoding` set. Since 2.12 it also sends cache validators (`ETag`/`Last-Modified`) and honors conditional requests with 304s, so reloads revalidate instead of re-downloading every asset. `darkmown dev` is deliberately uncompressed and validator-free.
88
88
  - `darkmown catalog [--llms|--llms-full]` prints the machine-readable directive catalog (JSON), a compact cheatsheet with `--llms`, or the complete reference including every error code with `--llms-full`. See [AI authoring](#ai-authoring).
89
+ - `darkmown mcp` runs a zero-dependency MCP server on stdio, exposing the catalog, grammar, validation, error codes, and docs as agent tools. See [MCP server](#mcp-server-darkmown-mcp).
89
90
  - `darkmown version` prints the installed package version.
90
91
  - `darkmown help` prints CLI usage.
91
92
 
@@ -285,7 +286,9 @@ And one key moves styling into frontmatter: **`skins: [/base.skin, /marketing.sk
285
286
 
286
287
  A few reserved keys drive **drafts and feeds** (see [SEO & feeds](#seo--feeds-sitemap-rss-robots) below): `draft: true` excludes a page from production builds; `site_url` on the home page turns on `sitemap.xml` + `rss.xml`; `rss_limit:` on the home page caps how many posts the feed carries; `date:` marks a page as a blog post (it lands in `rss.xml` and sets the page's `<lastmod>`); `excerpt:` is the RSS summary. The same frontmatter is queryable when the page is an entry in a [content collection](#content-collections): `{ post.date }`, `{ post.excerpt }`, `{ post.tags }`, and any custom key resolve in an `@loop` over the folder.
287
288
 
288
- `transitions: true` opts a page into **instant, flash-free navigation** — zero JavaScript, all declarative. It emits a directional fade+slide **view transition** for the page swap (old lifts up and out, new rises up and in — replacing the default cross-fade, which left both pages ghosted at ~50 % opacity mid-navigation), plus a **`<script type="speculationrules">` prerender** hint that renders the next same-origin page on hover/pointerdown so the click activates an already-painted page (no white render-gap flash). It honors `prefers-reduced-motion`. Only same-origin pages that both opt in transition; browsers without support — or with page-preloading disabled — navigate normally. Off by default; opt out with `transitions: false`. Mark a link `{.no-prefetch}` to exclude it from prerendering. (Chrome disables prerendering while DevTools is open, so test the built site with DevTools closed.)
289
+ **Markdown sources: `md_source: true`** on the home page opts the whole site in to publishing every page's byte-exact source beside its HTML: `/docs/` also answers `/docs.md` (the exact `.md`/`.wd` file the author wrote, frontmatter and CRLF endings included) with a `text/markdown` content type, and each published page carries `<link rel="alternate" type="text/markdown" href="/docs.md" title="Markdown source">`, emitted exactly when the file is, so the link never 404s. A page opts back out with `md_source: false`; a page-level `true` without the home flag does nothing, drafts never emit a source, and sitemap/rss/robots never list the `.md` files (they are alternates, not routes). Off by default, and a site that never sets it builds byte-identical output.
290
+
291
+ `transitions: true` opts a page into **instant, flash-free navigation**: zero JavaScript, all declarative. It emits an opacity-only **cross-fade view transition** for the page swap (old and new animate simultaneously at 160ms with one shared easing, since 2.12; the earlier directional slide read as a flicker mid-navigation), plus a **`<script type="speculationrules">` prerender** hint that renders the next same-origin page on hover/pointerdown so the click activates an already-painted page (no white render-gap flash). It honors `prefers-reduced-motion`. Only same-origin pages that both opt in transition; browsers without support, or with page-preloading disabled, navigate normally. Off by default; opt out with `transitions: false`. Mark a link `{.no-prefetch}` to exclude it from prerendering. (Chrome disables prerendering while DevTools is open, so test the built site with DevTools closed.)
289
292
 
290
293
  ## Static assets
291
294
 
@@ -312,7 +315,9 @@ Reference them with a normal URL: `![logo](/__wd/media/logo.svg)`. The preview/`
312
315
  - **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
316
  - 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
317
 
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.
318
+ **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, and a page with no resizable images emits exactly what it did before.
319
+
320
+ The build still never invents a `sizes` value, but since 2.12 you can set one on the Markdown image itself with the inline-attribute block: `![hero](hero.jpg){sizes="(max-width: 640px) 100vw, 640px"}` passes `sizes` through onto the `<img>`, composing with `.class`/`#id` and with the wired `srcset`. Without `sizes`, browsers assume `100vw` and can fetch the full-size original on desktop, which makes the variants dead weight, so a hero image should carry one. Raw `<img srcset sizes>` on an `html: true` page remains the escape hatch (raw-HTML images are author territory: nothing is added or rewritten there).
316
321
 
317
322
  **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.
318
323
 
@@ -1258,7 +1263,9 @@ A trailing `{.class .class #id}` attaches classes / an id to the inline element
1258
1263
  ![logo](/logo.svg){.brand}
1259
1264
  ```
1260
1265
 
1261
- The block must follow the element with no space, and works on links, images, and emphasis. It does **not** attach to inline code: `` `x` ``{.hl} leaves the braces in the output as literal text. It never collides with `{ name }` interpolation, because an interpolation always starts with a name, never a `.` or `#`.
1266
+ The block must follow the element with no space, and works on links, images, and emphasis. It does **not** attach to inline code: `` `x` ``{.hl} leaves the braces in the output as literal text. It never collides with `{ name }` interpolation, because an interpolation always starts with a name, never a `.`, `#`, or `sizes=`.
1267
+
1268
+ On an **image** the block also accepts `sizes="…"`, in any order alongside the classes and id, double-quoted and free to contain media conditions with spaces and commas: `![hero](hero.jpg){sizes="(max-width: 640px) 100vw, 640px"}` passes the attribute through, HTML-escaped, whether or not the image has a wired `srcset` (with variants it rides alongside them on the same `<img>`). That matters because a `srcset` without `sizes` makes browsers assume `100vw` and fetch the full-size original on desktop. On a link or emphasis target a `sizes` token is ignored, and a block that matches nothing recognized (like `{foo="x"}`) still renders as literal text.
1262
1269
 
1263
1270
  **Headings get anchors for free.** Every markdown heading carries a stable, GitHub-style slug `id` at build time (lowercased, punctuation stripped, whitespace → hyphens; duplicates dedupe with `-1`/`-2` suffixes across the whole document), so any section of any page is deep-linkable with a plain `#the-slug` fragment — zero JS. The [docs page's](https://darkmown.com/docs/) "On this page" table of contents is just markdown links to those anchors.
1264
1271
 
@@ -1439,6 +1446,8 @@ Both are optional dependencies, lazily loaded in a child process behind the same
1439
1446
  - **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
1447
  - 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
1448
 
1449
+ **Font preloads are injected automatically.** The same post-pass adds one `<link rel="preload" as="font" crossorigin>` per project font to every emitted page, right after the last stylesheet link, mapped per page from the fonts the page's own stylesheets declare (a page whose CSS declares no project fonts is byte-identical). The preloaded URL is exactly the one the rewritten CSS leads with: the `-subset.woff2` when subsetting ran, else the original project font (preloading the unsubsetted original still beats no preload), with `type` matching the extension. `crossorigin` is mandatory because font fetches are always CORS-mode; without it the browser downloads the font twice. Remote `http(s)` srcs are never preloaded, already-`.woff2` project fonts are preloaded as-is, and the injection is idempotent, so incremental dev rebuilds never duplicate a link. The payoff: no fallback-font swap and reflow on first paint.
1450
+
1442
1451
  ## The escape hatch
1443
1452
 
1444
1453
  Reactive pages expose `window.wd` so a colocated `.js` file can do anything the directives deliberately don't — keyboard, drag/touch, canvas, charts, maps:
@@ -1511,7 +1520,7 @@ Every author-facing compile error opens with a stable `WDxxx` code, so it is sea
1511
1520
  Use: :state name = value [persist|ephemeral] — e.g. :state count = 0
1512
1521
  ```
1513
1522
 
1514
- The full list lives in [`docs/errors.md`](https://github.com/ZVN-DEV/darkmown/blob/master/docs/errors.md): code, cause, fix, and a compilable example each. (It is generated into the repository, not the published tarball; `darkmown catalog --llms-full` prints the same content offline.) Codes are grouped by subsystem:
1523
+ The full list lives in [`docs/errors.md`](https://github.com/ZVN-DEV/darkmown/blob/master/docs/errors.md): code, cause, fix, and a compilable example each. (Since 2.11 it also ships in the published tarball, so the `darkmown://docs/errors` MCP resource and offline readers get the same file; `darkmown catalog --llms-full` prints the same content offline.) Codes are grouped by subsystem:
1515
1524
 
1516
1525
  | Range | Subsystem |
1517
1526
  | --- | --- |
@@ -1580,6 +1589,37 @@ session.call("validate", {});
1580
1589
 
1581
1590
  `toolPrompt()` renders the tool list for a system prompt, and `parseToolCall(reply)` reads a call back out of a model's answer (brace-counting and string-aware, because the one thing a Darkmown edit reliably contains is `{ count }`).
1582
1591
 
1592
+ ### MCP server: `darkmown mcp`
1593
+
1594
+ `darkmown mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so an MCP-capable agent (Claude Code, Codex, Cursor, …) gets the language description and the compiler as first-class tools instead of shelling out. It is hand-rolled and zero-dependency: MCP over stdio is newline-delimited JSON-RPC 2.0 (revision 2024-11-05), so there is no SDK in `dependencies` and nothing to drift. Every tool is a thin adapter over the same drift-guarded surfaces the CLI uses, so an MCP client and the `darkmown` CLI can never disagree about what compiles or what a WD code means. stdout carries protocol frames only; logs go to stderr.
1595
+
1596
+ | Tool | Answers |
1597
+ | --- | --- |
1598
+ | `catalog` | The directive catalog: `mode "json"` (default), `"llms"` (the cheatsheet), or `"llms-full"` (the complete reference). |
1599
+ | `grammar` | The GBNF directive-line grammar (`gbnf: true`), or a category slice of the cheatsheet (`categories: ["state", "actions"]`). |
1600
+ | `validate` | Compile `.wd`/`.md` source in memory: static/reactive status and warnings on success, the WD code, file:line, `Use:` hint, and example on failure. |
1601
+ | `error` | One WD code's cause, fix, and example; an unknown code answers the valid list. |
1602
+ | `doc` | Fuzzy-find a section of the shipped README/AGENTS.md by topic (`topic: "includes"`; no topic lists every section). |
1603
+
1604
+ Five resources expose the shipped docs at `darkmown://docs/readme`, `darkmown://docs/agents`, `darkmown://docs/changelog`, `darkmown://docs/errors`, and `darkmown://docs/cli`, read from the installed package at request time.
1605
+
1606
+ Client config (Claude Code, Cursor, and friends share this shape):
1607
+
1608
+ ```json
1609
+ {
1610
+ "mcpServers": {
1611
+ "darkmown": {
1612
+ "command": "npx",
1613
+ "args": ["-y", "@zvndev/darkmown", "mcp"]
1614
+ }
1615
+ }
1616
+ }
1617
+ ```
1618
+
1619
+ Deliberately out of scope: prompts, logging, completion, sampling, subscriptions, and batched requests (a JSON array gets a single Invalid Request error, which the spec permits).
1620
+
1621
+ **Any page as plain markdown.** With [`md_source: true`](#frontmatter) on the home page, every route also serves its byte-exact source at `<route>.md` (`/docs/` answers `/docs.md`), advertised with a `<link rel="alternate" type="text/markdown">` head tag, so an agent can read any page without parsing HTML. `llms-full.txt` already carries the full source text of every page; the `.md` sources are the per-page, always-current form of the same idea.
1622
+
1583
1623
  ## Editor support
1584
1624
 
1585
1625
  A VS Code extension in [`editors/vscode`](https://github.com/ZVN-DEV/darkmown/tree/master/editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown.
package/docs/cli.md CHANGED
@@ -114,6 +114,7 @@ Since 2.8 the preview is at parity with dev for two things:
114
114
 
115
115
  - **`api/` functions run.** A request to `/api/*` that matches a handler under the project's top-level `api/` directory executes it (same contract and file routing as `darkmown dev` and the production hosts: `api/echo.js` → `/api/echo`, `api/users/[id].js` → `/api/users/:id`); anything else falls through to static serving. `:form action="/api/…"` posts and `:fetch … from "/api/…"` demos that worked in dev now work in the preview too, and editing a handler is picked up without a restart. A project with no `api/` directory behaves exactly as before. Since 2.10.1 the local runner matches production hygiene: request bodies are capped at 10 MB (`413` over cap), a malformed percent-encoded dynamic segment answers `400`, and under `serve` a throwing handler answers the generic `{"ok": false, "error": "Internal error"}` while the real error is logged server-side only. `darkmown dev` keeps the verbose message, because in dev the developer is the audience.
116
116
  - **Text responses are compressed.** The server negotiates off the request's `Accept-Encoding` (brotli preferred, then gzip, q-values honored) and sets `Vary: Accept-Encoding`, appending to an existing `Vary` rather than clobbering it. Compressible: `text/*`, `application/javascript`, `application/json`, `application/xml`, and `image/svg+xml`, across static files, the built 404 page, and `api/` responses (a handler that set its own `Content-Encoding` is never double-compressed). Images, video, audio, and fonts are never compressed. `darkmown dev` is intentionally uncompressed, byte-identical to 2.7.
117
+ - **Static responses carry cache validators.** Every static 200 (and the built 404) sends `Last-Modified` and a strong `ETag`; `If-None-Match` (comma-lists and `*` honored) and `If-Modified-Since` are answered with a 304 carrying the validators and no body, with `If-None-Match` taking precedence per RFC 7232. Cache-Control by file class: HTML gets `no-cache` (always revalidate, but revalidations are cheap 304s), anything under `/__wd/` and media/font extensions anywhere get `public, max-age=3600` (unchanged between builds, and any rebuild moves the validators), and everything else stays deliberately unopinionated at `no-cache`. The freshness check runs before compression work, and `api/` responses are untouched. Reloads therefore revalidate instead of re-downloading every asset. `darkmown dev` sends none of this, by design.
117
118
 
118
119
  ### `darkmown catalog [--llms|--llms-full]`
119
120
 
@@ -129,6 +130,25 @@ darkmown catalog --llms-full > reference.md
129
130
 
130
131
  Every `darkmown build` also writes both files (`dist/llms.txt` and `dist/llms-full.txt`), and Darkmown ships a generated GBNF grammar (`grammar/wd-directives.gbnf`) for constrained decoding.
131
132
 
133
+ ### `darkmown mcp`
134
+
135
+ Runs a Model Context Protocol server on stdio, so an MCP-capable agent gets the language description and the compiler as first-class tools. It is hand-rolled with zero dependencies: MCP over stdio is newline-delimited JSON-RPC 2.0 (protocol revision 2024-11-05), stdout carries protocol frames only, and logs go to stderr. Every tool is a thin adapter over the same drift-guarded surfaces the CLI uses, so a client and the CLI can never disagree.
136
+
137
+ Five tools: `catalog` (the directive catalog as `json`, `llms`, or `llms-full`), `grammar` (the full GBNF grammar, or a category slice of the cheatsheet), `validate` (compile `.wd`/`.md` source in memory: static/reactive status and warnings on success, the WD code with file:line, `Use:` hint, and example on failure), `error` (one WD code's cause, fix, and example, or the valid list), and `doc` (fuzzy section search over the shipped README and AGENTS.md). Five resources expose the shipped docs at `darkmown://docs/readme`, `darkmown://docs/agents`, `darkmown://docs/changelog`, `darkmown://docs/errors`, and `darkmown://docs/cli`, read from the installed package at request time. Prompts, logging, completion, sampling, subscriptions, and batched requests are deliberately not implemented.
138
+
139
+ Client config (Claude Code, Cursor, and friends share this shape):
140
+
141
+ ```json
142
+ {
143
+ "mcpServers": {
144
+ "darkmown": {
145
+ "command": "npx",
146
+ "args": ["-y", "@zvndev/darkmown", "mcp"]
147
+ }
148
+ }
149
+ }
150
+ ```
151
+
132
152
  ## Backends (`api/`)
133
153
 
134
154
  Backend endpoints are plain-JS Web-standard handlers in a top-level `api/` directory — the same shape on every host, and the only thing you write for a server:
package/docs/errors.md ADDED
@@ -0,0 +1,255 @@
1
+ # Darkmown compile error codes
2
+
3
+ Every Darkmown compile error carries a stable `WDxxx` code. It is the first
4
+ thing in the message (`[WD201] Malformed :state in …`) and it is mirrored on
5
+ the thrown error as `err.wd.code`, alongside `file`, `line`, `hint`, and a
6
+ concrete compilable `example`.
7
+
8
+ ```
9
+ [WD201] Malformed :state in site/pages/index.wd:1: :state x.
10
+ Use: :state name = value [persist|ephemeral] — e.g. :state count = 0
11
+ ```
12
+
13
+ Codes are grouped by subsystem, and a shipped code is permanent: it is never
14
+ renumbered and a retired number is never reused. Add new errors at the next
15
+ free number in their block.
16
+
17
+ | Range | Subsystem |
18
+ | --- | --- |
19
+ | `WD0xx` | Source, frontmatter & block structure |
20
+ | `WD1xx` | Loops & collections |
21
+ | `WD2xx` | State & expressions |
22
+ | `WD3xx` | Button, effect & timer actions |
23
+ | `WD4xx` | Forms & form fields |
24
+ | `WD5xx` | Data fetching & URL safety |
25
+ | `WD6xx` | Includes & page structure |
26
+ | `WD7xx` | Media & embeds |
27
+ | `WD8xx` | Skins & styling |
28
+ | `WD9xx` | Project, routing & CLI |
29
+
30
+ > This page is generated from `src/errors.js` by `node scripts/gen-errors.mjs`.
31
+ > Edit the registry, not this file.
32
+
33
+ ## WD0xx — Source, frontmatter & block structure
34
+
35
+ | Code | Error | Cause | Fix | Example |
36
+ | --- | --- | --- | --- | --- |
37
+ | `WD001` | Unterminated frontmatter | The file opens with `---` but never closes the block. | Add a closing `---` on its own line before the page body. | |
38
+ | `WD002` | Disallowed path segment | A dotted path contains `constructor`, `prototype`, or `__proto__`. | Read a different path; those segments are rejected everywhere for safety. | |
39
+ | `WD003` | Cannot interpolate an object value | `{ name }` resolved to an object, which has no sensible text form. | Interpolate one of its fields (`{ name.title }`) or iterate it with `@loop`. | |
40
+ | `WD004` | Unbalanced JSON value | A `:state`/`:store`/`:theme` value opens `[` or `{` and never closes it. | Balance the literal (no blank lines inside it), or quote it as text. | `:state count = 0` |
41
+ | `WD005` | Loop variable used outside a loop | `{ $index }` (or another `$` row variable) appears outside a `@loop` body. | Move the interpolation into a loop body, or rename your value. | |
42
+ | `WD006` | Unknown format pipe | `{ value \| name }` names a formatter that is not in the fixed whitelist. | Use one of the catalogued pipes; the message lists every available name. | |
43
+ | `WD007` | Stray `:::` close | A bare `:::` line appears with no container open. | Open the container first, or delete the stray closer. | `::: card .featured` |
44
+ | `WD008` | Retired `@repeat` directive | `@repeat` was replaced by the single `@loop` directive. | Rewrite the block as `@loop … into … @endloop`. | `@loop /products.json into p where p.price < 50 sort by p.price asc` |
45
+ | `WD009` | Retired `:for` directive | `:for` was replaced by the single `@loop` directive. | Rewrite the block as `@loop … into … @endloop`. | `@loop /products.json into p where p.price < 50 sort by p.price asc` |
46
+ | `WD010` | Stray block closer | An `@endloop`/`:endif`/`:endform`/`:else` line has no matching opener. | Open the matching block, or delete the stray closer. | |
47
+ | `WD011` | Missing block closer | A `@loop`/`:form`/`:carousel` block runs to the end of the file unclosed. | Close the block with its own closing token. | |
48
+ | `WD012` | Missing closing `:::` | A `::: container` block is never closed. | Add a bare `:::` line to close the container. | `::: card .featured` |
49
+ | `WD013` | `:else if` after `:else` | A conditional continues with `:else if` after a bare `:else` already closed it. | Order every `:else if` before the bare `:else`. | |
50
+ | `WD014` | Duplicate `:else` | A conditional has more than one bare `:else` branch. | Keep one `:else`; turn the others into `:else if` branches. | |
51
+ | `WD015` | Missing `:endif` | An `:if` region is never closed. | Close the region with `:endif`. | `:if count > 0` |
52
+ | `WD016` | Unknown structured-data type | Frontmatter `schema:` names a type outside the supported whitelist. | Use one of the listed schema.org types; the message names every one. | |
53
+ | `WD017` | Invalid `skins:` frontmatter entry | A `skins:` entry is missing, is not a `.skin` file, escapes `site/pages`/`site/_`, or the value is not an inline array of paths. | Write `skins: [/base.skin]` — an inline array of .skin paths inside site/pages or site/_. | |
54
+
55
+ ## WD1xx — Loops & collections
56
+
57
+ | Code | Error | Cause | Fix | Example |
58
+ | --- | --- | --- | --- | --- |
59
+ | `WD101` | Malformed `@loop` header | The header does not read `@loop <source> into <item>`. | Name a source and an item variable, then any clauses in the fixed order. | `@loop /products.json into p where p.price < 50 sort by p.price asc` |
60
+ | `WD102` | Malformed `@loop` clause | A clause is misspelled or written out of the fixed order. | Order clauses `where`, `sort by`, `reverse`, `offset`, `limit`, `paginate`, `sortable`. | `@loop /products.json into p where p.price < 50 sort by p.price asc` |
61
+ | `WD103` | Unknown `sort by { state }` key | A reactive sort key references a `:state`/`:store` that is not declared. | Declare the state first, or sort by a literal item field. | `@loop /products.json into p where p.price < 50 sort by p.price asc` |
62
+ | `WD104` | Sort key is not a loop-item field | `sort by` names something other than the loop item. | Sort by `<item>.field`, using the item name from the `into` clause. | `@loop /products.json into p where p.price < 50 sort by p.price asc` |
63
+ | `WD105` | Disallowed sort key path | The sort key contains `constructor`, `prototype`, or `__proto__`. | Sort by an ordinary data field. | |
64
+ | `WD106` | Unknown sort direction state | A reactive `{ direction }` references a `:state`/`:store` that is not declared. | Declare the state first, or use the literal `asc`/`desc`. | `@loop /products.json into p where p.price < 50 sort by p.price asc` |
65
+ | `WD107` | Invalid `offset`/`limit` argument | The argument is neither a non-negative integer nor a declared `:state`. | Pass an integer literal or a declared state key. | `@loop /products.json into p where p.price < 50 sort by p.price asc` |
66
+ | `WD108` | `sortable` combined with other clauses | `sortable` reorders the underlying list, so a derived view would desynchronise. | Drop `where`/`sort`/`reverse`/`offset`/`limit`/`paginate` from a sortable loop. | |
67
+ | `WD109` | `paginate` combined with `offset`/`limit` | `paginate` already slices each page, so an explicit slice conflicts with it. | Keep `paginate N` and remove `offset`/`limit`. | |
68
+ | `WD110` | `paginate` on a non-collection source | Pagination multiplies static routes, which only makes sense for a collection. | Paginate a collection (a `site/pages/<name>/` subdirectory) by its bare name. | |
69
+ | `WD111` | Loop data file is not a JSON array | The `.json` source parses to an object or a scalar. | Make the file a top-level JSON array of rows. | |
70
+ | `WD112` | In-scope loop source is not a list | The name resolved to a scalar or object value in scope. | Point the loop at a list, or omit the field to render the `@empty` branch. | |
71
+ | `WD113` | Disallowed `@loop` source path | The source path contains `constructor`, `prototype`, or `__proto__`. | Loop an ordinary collection, JSON file, scope value, or state list. | |
72
+ | `WD114` | `sortable` on a nested loop | An item-relative loop has no top-level state list to reorder. | Make the sortable loop read a top-level `:state`/`:store` list. | |
73
+ | `WD115` | Unresolved `@loop` source | The source is not a collection, JSON file, in-scope value, or declared state. | Check the name; the message lists the available collections. | `@loop /products.json into p where p.price < 50 sort by p.price asc` |
74
+ | `WD116` | Reactive loop nested too deep | A third reactive `@loop` level would paint empty at runtime. | Unroll the outer data at build time, or move the innermost list into build data. | |
75
+ | `WD117` | `sortable` needs a state list | `sortable` was used on a JSON file or in-scope value, which cannot be reordered. | Reorder a `:state`/`:store` list instead. | |
76
+ | `WD120` | Malformed `_schema.wd` line | A schema line does not read `field: type`. | Write one `field: type` rule per line inside the `---` block. | |
77
+ | `WD121` | Unknown `_schema.wd` type | The type is outside the closed vocabulary. | Use `string`, `number`, `boolean`, `date`, or `string[]`, with `?` for optional. | |
78
+ | `WD122` | Undeclared frontmatter field | A collection entry has a field the schema does not declare (often a typo). | Add the field to `_schema.wd`, or remove it from the entry. | |
79
+ | `WD123` | Missing required frontmatter field | A collection entry omits a field the schema requires. | Add the field to the entry, or mark it optional with `?` in the schema. | |
80
+ | `WD124` | Frontmatter field has the wrong type | An entry's value does not match its schema type. | Fix the entry's value, or widen the type in `_schema.wd`. | |
81
+ | `WD190` | Expression could not be compiled | A `:if` or `::: … when` condition folded a build-time value the expression re-parser cannot read back. | Use simpler operands — a field path, a declared `:state`, a plain number, or a `"string"`. | |
82
+ | `WD191` | Reactive `@loop` over markdown table rows | A `@loop` whose body is bare `\| … \|` cells resolves to a reactive source, and a reactive row is cloned into a `<div>`, which cannot live inside a `<table>`. | Loop a static source (a JSON file, a frontmatter list, or a collection) for a markdown table — and if the source is already static, drop the `:state` the `where`/`limit`/`sort by` clause reads — or build reactive rows from containers (`::: trow` / `::: td`) instead of `\|` cells. | |
83
+
84
+ ## WD2xx — State & expressions
85
+
86
+ | Code | Error | Cause | Fix | Example |
87
+ | --- | --- | --- | --- | --- |
88
+ | `WD201` | Malformed `:state` | The line does not read `:state name = value [persist\|ephemeral]`. | Give the state a name and an initial value. | `:state count = 0` |
89
+ | `WD202` | `:state` inside a reactive loop | State cannot be declared per row; a loop body has no place to hold it. | Declare the state outside the loop. | |
90
+ | `WD203` | `:state` collides with a `:store` | A page-global store already owns that name. | Rename one of them, or use the store everywhere. | `:store cart = []` |
91
+ | `WD204` | `:state` declared twice | The same name is declared twice in one section scope. | Remove the duplicate, or scope one declaration to its own `::: section`. | |
92
+ | `WD205` | Malformed `:store` | The line does not read `:store name = value [persist\|ephemeral]`. | Give the store a name and an initial value. | `:store cart = []` |
93
+ | `WD206` | `:store` declared twice | Stores are page-global, so a second declaration is ambiguous. | Declare the store once and reference it everywhere else. | `:store cart = []` |
94
+ | `WD207` | `:store` collides with a `:state` | A `:state` of the same name is already declared on the page. | Rename one of them, or use the store everywhere. | `:store cart = []` |
95
+ | `WD208` | Malformed `:computed` | The line does not read `:computed name = <expression>`. | Give the computed value a name and a right-hand expression. | `:computed total = items.length * 4` |
96
+ | `WD209` | Malformed `:computed` expression | The right-hand side passes the character whitelist but is not a real expression. | Write a complete expression over declared state, numbers, and operators. | `:computed total = items.length * 4` |
97
+ | `WD210` | Malformed `:theme` | The line does not read `:theme [name] [= "auto"]`. | Use a bare `:theme`, or name the store and seed it. | `:theme` |
98
+ | `WD211` | Persistence token on `:computed` | The expression ends in a bare `persist`, `ephemeral`, or `from-url`, which is swallowed into it. | Computed values are derived, not stored. Put the token on the state they derive from instead. | `:computed total = items.length * 4` |
99
+ | `WD220` | Malformed `where` condition | A `where` condition is not `operand <op> operand`. | Compare a loop-item field with a value using a whitelisted operator. | |
100
+ | `WD221` | Unsupported `where` operand | An operand is not an item field, a state name, a number, or a quoted string. | Use one of those four operand forms. | |
101
+ | `WD222` | Disallowed `where` path | An operand path contains `constructor`, `prototype`, or `__proto__`. | Compare an ordinary data field. | |
102
+ | `WD223` | Unknown name in `where` | An operand names neither the loop item nor a declared `:state`. | Use the loop item's field, or declare the state first. | |
103
+ | `WD224` | Unsupported `when`/`:if` operand | An operand is not an item field, a state name, a number, or a quoted string. | Use one of those four operand forms. | `:if count > 0` |
104
+ | `WD225` | Disallowed `when`/`:if` path | An operand path contains `constructor`, `prototype`, or `__proto__`. | Compare an ordinary data field. | |
105
+ | `WD226` | Unknown name in `when`/`:if` | An operand names nothing in scope: no loop item, no scope value, no state. | Declare the state first, or reference a value that is in scope. | `:if count > 0` |
106
+ | `WD227` | Disallowed aggregate path | A `sum`/`avg`/`min`/`max`/`count` argument contains a prototype-poisoning segment. | Aggregate an ordinary state list. | |
107
+ | `WD228` | Unknown state in a `:computed` aggregate | The aggregated list is not a declared `:state`/`:store`/`:fetch` key. | Declare the list first. | `:computed total = items.length * 4` |
108
+ | `WD229` | Unsupported string syntax in `:computed` | The expression contains a quote, backslash, or backtick outside a simple literal. | Use plain double- or single-quoted string literals. | |
109
+ | `WD230` | Unsupported syntax in `:computed` | The expression contains characters outside the closed whitelist. | Use state names, numbers, strings, arithmetic, comparisons, and `&& \|\| !`. | `:computed total = items.length * 4` |
110
+ | `WD231` | Assignment in `:computed` | A `:computed` expression derives a value; it may not assign one. | Mutate state from a `:button`/`:effect` action instead. | |
111
+ | `WD232` | Function call in `:computed` | Only the fixed `sum`/`avg`/`min`/`max`/`count` aggregates may look like calls. | Remove the call, or use one of the aggregates. | |
112
+ | `WD233` | Disallowed `:computed` path segment | A referenced path contains `constructor`, `prototype`, or `__proto__`. | Read an ordinary state path. | |
113
+ | `WD234` | Unknown state in `:computed` | The expression references a name that is not declared state. | Declare it with `:state`, `:store`, or `:fetch` first. | `:computed total = items.length * 4` |
114
+ | `WD250` | Reserved state declaration name | A `:state`/`:store`/`:computed`/`:fetch`/`:form`/`:slider`/`:theme` name is `__proto__`, `constructor`, or `prototype`, which the runtime's state object inherits rather than owns. | Rename the key — any other name works. | `:state count = 0` |
115
+ | `WD251` | State seeded from another state | A `:state`/`:store` value is a bare name that is already declared state, so it would be stored as that literal text and never track the value. | Derive it with `:computed`, or quote the text to keep it literal. | `:computed total = items.length * 4` |
116
+ | `WD260` | `from-url` on a keyword that cannot take it | `:store` and `:theme` are shared by every page and every tab, while a query parameter belongs to one page's address, so the two cannot mean the same thing. | Declare the value with `:state … from-url`, or drop the token. | `:state q = "" from-url` |
117
+ | `WD261` | Conflicting persistence modifiers | A declaration carries `persist` and `ephemeral` at once, or repeats the same modifier. | Pick one: `persist` keeps the value across reloads, `ephemeral` drops it. | `:state count = 0` |
118
+
119
+ ## WD3xx — Button, effect & timer actions
120
+
121
+ | Code | Error | Cause | Fix | Example |
122
+ | --- | --- | --- | --- | --- |
123
+ | `WD301` | Malformed `:button` | The line does not read `:button "Label" -> action`. | Quote the label and give at least one action after the arrow. | `:button "Add one" -> count++` |
124
+ | `WD302` | Malformed `:effect` | The line does not read `:effect watchedState -> action`. | Name one state to watch and the actions to run when it changes. | `:effect query -> searches++` |
125
+ | `WD303` | `:effect` watches unknown state | The watched name is not a declared `:state`/`:store`. | Declare the state first. | `:effect query -> searches++` |
126
+ | `WD304` | Malformed `:every` | The line does not read `:every <duration> -> action`. | Give a duration and the actions to run on each tick. | `:every 5s -> seconds++` |
127
+ | `WD305` | Invalid `:every` duration | The duration is not a positive `<int>[ms\|s\|m]` value. | Use a duration like `500ms`, `5s`, or `2m`. | `:every 5s -> seconds++` |
128
+ | `WD306` | Action targets unknown state | The action mutates a name that is not declared state. | Declare the state first. | `:button "Add one" -> count++` |
129
+ | `WD307` | Row append needs a list state | `list += item` carries the current row into a target that is not a list. | Declare the target as a list, e.g. `:state cart = []`. | |
130
+ | `WD308` | `+=` needs a number or a list | `+=` with a non-number value requires a list target to append to. | Add a number, or declare the target as a list. | |
131
+ | `WD309` | Row remove inside a nested loop | An item-relative loop has no top-level list for the runtime to remove from. | Carry the row into a top-level list first, then remove it there. | |
132
+ | `WD310` | Row remove targets the wrong list | Per-row `remove` must target the same list the loop is iterating. | Name the looped list, or remove a value instead of the current row. | |
133
+ | `WD311` | Unsupported action | The expression is outside the fixed action vocabulary (no JavaScript is allowed). | Use a catalogued op; the message lists the whole vocabulary. | `count++` |
134
+ | `WD312` | `merge` operand is unknown state | The right-hand name of a `merge` is not declared state. | Declare it first, or pass an inline object literal. | |
135
+ | `WD313` | Unsupported `merge` operand | The operand is neither a state key nor an inline object literal. | Merge a declared object state, or an inline `{…}` literal. | |
136
+ | `WD314` | Unsupported action literal | The action value is not a quoted string, number, boolean, null, or valid JSON. | Quote strings and use valid JSON for arrays and objects. | |
137
+ | `WD315` | `:every`/`:effect` inside a reactive `@loop` | A reactive loop clones its body per row, so the timer or watcher would be registered once per row and a removed row's would keep firing. | Declare it once outside the loop — at page level or inside the `:::` section — and act on the whole list. | `:every 5s -> seconds++` |
138
+
139
+ ## WD4xx — Forms & form fields
140
+
141
+ | Code | Error | Cause | Fix | Example |
142
+ | --- | --- | --- | --- | --- |
143
+ | `WD401` | Malformed `:form` | The opener has neither `into <name>` nor `action="…"`, or has leftover text. | Capture into state, post to a URL, or do both for a fetch round-trip. | `:form into contact` |
144
+ | `WD402` | Malformed `:input` | The line does not start with a field name. | Name the field, then add optional attributes and flags. | `:input email type=email required` |
145
+ | `WD403` | Unknown `:input` flag | The bare flag is not one of `required`/`autofocus`/`disabled`/`readonly`. | Use a supported flag, or write it as `name=value`. | `:input email type=email required` |
146
+ | `WD404` | Unknown `:input` attribute | The attribute is outside the supported set. | Use a supported attribute; the compiler emits no arbitrary HTML attributes. | `:input email type=email required` |
147
+ | `WD405` | Malformed `:bind` | The line does not start with the bound state name. | Name a declared `:state`, then add optional attributes. | `:bind query placeholder="Search"` |
148
+ | `WD406` | `:bind` has no matching state | The bound name is not a declared `:state`/`:store`. | Declare the state before binding to it. | `:bind query placeholder="Search"` |
149
+ | `WD407` | Unknown `:bind` flag | The bare flag is not `required` or `autofocus`. | Use a supported flag. | `:bind query placeholder="Search"` |
150
+ | `WD408` | Unknown `:bind` attribute | The attribute is outside the supported set. | Use `placeholder`, `type`, `autocomplete`, or an `aria-*` attribute. | `:bind query placeholder="Search"` |
151
+ | `WD409` | Malformed `:slider` | The line does not start with the slider's state name. | Name the state, then optionally seed it and set the range. | `:slider volume = 50 min=0 max=100 step=1` |
152
+ | `WD410` | Malformed `:slider` initial value | The `=` is present but no value follows it. | Give the slider a numeric initial value. | `:slider volume = 50 min=0 max=100 step=1` |
153
+ | `WD411` | Unknown `:slider` attribute | The attribute is not `min`, `max`, `step`, or `aria-label`. | Use a supported attribute. | `:slider volume = 50 min=0 max=100 step=1` |
154
+ | `WD412` | Non-numeric `:slider` bound | `min`, `max`, or `step` is not a number. | Give every bound a numeric value. | `:slider volume = 50 min=0 max=100 step=1` |
155
+ | `WD413` | Non-numeric `:slider` initial value | A slider is bound to a number, so its seed must be numeric. | Seed the slider with a number. | `:slider volume = 50 min=0 max=100 step=1` |
156
+ | `WD414` | `:slider persist` without an inline value | `persist` belongs to the declaration, and this slider binds existing state. | Declare the value inline to persist it, or persist it on the `:state` line. | `:slider volume = 50 min=0 max=100 step=1` |
157
+ | `WD415` | `:slider` has no matching state | The slider binds a name that is not declared state. | Declare the state, or seed it inline on the slider. | `:slider volume = 50 min=0 max=100 step=1` |
158
+ | `WD416` | Malformed `:submit` | The line does not read `:submit "Label"`. | Quote the button label. | `:submit "Send"` |
159
+ | `WD417` | Malformed `:textarea` | The line does not start with a field name. | Name the field, then add optional attributes and flags. | `:textarea message placeholder="Your message" rows=4` |
160
+ | `WD418` | Unknown `:textarea` flag | The bare flag is not one of `required`/`autofocus`/`disabled`/`readonly`. | Use a supported flag. | `:textarea message placeholder="Your message" rows=4` |
161
+ | `WD419` | Unknown `:textarea` attribute | The attribute is outside the supported set. | Use a supported attribute such as `placeholder`, `rows`, or `maxlength`. | `:textarea message placeholder="Your message" rows=4` |
162
+ | `WD420` | Malformed `:select` | The line does not start with a field name. | Name the field, then list the options as `- Label` lines. | `:select topic` |
163
+ | `WD421` | Unknown `:select` flag | The bare flag is not `required`, `disabled`, or `autofocus`. | Use a supported flag. | `:select topic` |
164
+ | `WD422` | Unknown `:select` attribute | The attribute is not `autocomplete` or an `aria-*` attribute. | Use a supported attribute. | `:select topic` |
165
+ | `WD423` | `:select` has no options | No `- Label` lines follow the opener. | Add one `- Label` line per option directly beneath it. | `:select topic` |
166
+ | `WD424` | Malformed `:checkbox`/`:radio` | The line does not start with a group name. | Name the group, then list the options as `- Label` lines. | `:checkbox toppings` |
167
+ | `WD425` | Unknown `:checkbox`/`:radio` flag | The bare flag is not `required`, `disabled`, or `autofocus`. | Use a supported flag. | `:checkbox toppings` |
168
+ | `WD426` | Unknown `:checkbox`/`:radio` attribute | The attribute is not `aria-label` or `aria-describedby`. | Use a supported attribute. | `:checkbox toppings` |
169
+ | `WD427` | `:checkbox`/`:radio` has no options | No `- Label` lines follow the opener. | Add one `- Label` line per option directly beneath it. | `:radio size` |
170
+ | `WD450` | Bound field with no state | A `:select`/`:radio`/`:checkbox` outside a `:form` names state that is not declared, so there is nothing for it to bind to. | Declare the state first, or move the field inside a `:form`, where the name is a form field instead. | `:select topic` |
171
+ | `WD451` | Bound `:checkbox` with several options | A `:checkbox` bound to state carries one true/false, so a list of options has no meaning. | Give it one `- Label` line, or use a `:radio` group for a set of choices. | `:checkbox toppings` |
172
+ | `WD452` | File field in a GET form | A `:form` contains an `<input type=file>` and declares `method="get"`. A GET request has no body, so only the file's name would travel. | Drop `method="get"` — a `:form` posts by default, and a form with a file field is submitted as multipart. | `:form into contact` |
173
+
174
+ ## WD5xx — Data fetching & URL safety
175
+
176
+ | Code | Error | Cause | Fix | Example |
177
+ | --- | --- | --- | --- | --- |
178
+ | `WD501` | Malformed `:fetch` | The line does not read `:fetch name from "url" [options]`. | Name the state key and quote the URL, then add keyword options. | `:fetch todos from "/api/todos.json" when=visible` |
179
+ | `WD502` | Unknown `:fetch` option | An option is not a `name=value` pair from the supported set. | Use `method`, `when`, `timeout`, `retry`, `headers`, `body`, or `refresh`. | `:fetch todos from "/api/todos.json" when=visible` |
180
+ | `WD503` | Unsupported `:fetch` method | The method is outside `GET`/`POST`/`PUT`/`PATCH`/`DELETE`. | Use one of the supported HTTP methods. | `:fetch todos from "/api/todos.json" when=visible` |
181
+ | `WD504` | Unsupported `:fetch when` | `when` is neither `load` nor `visible`. | Fetch on `load` (the default) or when the marker becomes `visible`. | `:fetch todos from "/api/todos.json" when=visible` |
182
+ | `WD505` | Non-integer `:fetch timeout`/`retry` | The value is not a non-negative integer. | Pass whole numbers (milliseconds for `timeout`, attempts for `retry`). | `:fetch todos from "/api/todos.json" when=visible` |
183
+ | `WD506` | `:fetch refresh=` without `headers=` | A token refresh has nowhere to write the renewed token back to. | Add `headers=<stateKey>` alongside `refresh=`. | `:fetch todos from "/api/todos.json" when=visible` |
184
+ | `WD507` | Unsafe URL | The URL is empty, padded with whitespace, or contains control characters. | Use a clean relative path, an http(s) URL, or a `{ state }` interpolation. | |
185
+ | `WD508` | Protocol-relative URL | A `//host` URL inherits whatever scheme the page was served over. | Write `http://` or `https://` explicitly. | |
186
+ | `WD509` | Disallowed URL scheme | The scheme is not `http` or `https` (`javascript:`, `data:`, `file:`, and friends). | Use http(s) or a relative path. | |
187
+
188
+ ## WD6xx — Includes & page structure
189
+
190
+ | Code | Error | Cause | Fix | Example |
191
+ | --- | --- | --- | --- | --- |
192
+ | `WD601` | Include outside the source sandbox | The include path escapes `site/pages` and `site/_`. | Move the partial inside the sandbox and reference it from there. | `@include /header.wd` |
193
+ | `WD602` | Include not found | No file matched the spec in `site/pages` or `site/_`. | Check the path, the extension, and the leading slash. | `@include /header.wd` |
194
+ | `WD603` | Malformed `@include` | The line does not read `@include /partial.wd [with key="value"]`. | Give one target path, then optional `with` arguments. | `@include /header.wd` |
195
+ | `WD604` | Include argument is not in scope | A `with key={ value }` argument references a name that does not resolve. | Pass a literal, or reference a value that is in scope at the include site. | `@include /header.wd` |
196
+ | `WD605` | Unexpected token in a container header | The header holds something that is not a name, `.class`, or `#id`. | Use a leading name, then `.class`/`#id` tokens. | `::: card .featured` |
197
+ | `WD606` | Malformed `:carousel` | The opener carries something other than `autoplay=<ms>`. | Open it bare, or with a numeric `autoplay`. | `:carousel autoplay=3000` |
198
+ | `WD607` | Loop variable in `:if` outside a loop | `:if $first` (or another row variable) appears outside a `@loop` body. | Move the conditional into the loop body. | |
199
+ | `WD608` | `:if` name is not declared | The condition names neither a `:state` nor an in-scope value. | Declare the state first, or reference a value that is in scope. | `:if count > 0` |
200
+ | `WD609` | Malformed `:if` | The `:if` line carries no condition. | Give a name, or a comparison joined with `and`/`or`/`not`. | `:if count > 0` |
201
+ | `WD610` | Unsafe `:try` href | The href has control characters, or a scheme outside http/https/mailto. | Use a relative URL, or an `http:`, `https:`, or `mailto:` URL. | |
202
+ | `WD611` | Protocol-relative `:try` href | A `//host` href inherits whatever scheme the page was served over. | Write `http:` or `https:` explicitly. | |
203
+ | `WD612` | Include cycle | An `@include` chain reaches a file that is already being compiled, so the compile would never terminate. | Break the loop: remove the `@include` that points back, or move the shared content into a third file both sides include. | |
204
+ | `WD613` | Invalid include `defaults` | An include file's `defaults:` frontmatter is not an inline array of `name="value"` entries. | Write an inline array of quoted entries, e.g. `defaults: [title="Untitled"]`. Values are strings; a caller-provided `with` argument always wins over a default. | `@include /header.wd` |
205
+ | `WD614` | Stray `@endinclude` | An `@endinclude` has no unpaired `@include` before it — the closer pairs with the nearest preceding unpaired opener, and every opener in range already paired. | Remove the extra closer, or open a block above it: `@include /partial.wd`, the children lines, then `@endinclude`. | `@include /header.wd` |
206
+ | `WD616` | Include block nested too deep | A block-form `@include … @endinclude` sits inside another include block's children or inside a nested include's own body, past the one supported level. | Keep one level: move the inner block into the include's own file, or use the inline form `@include /partial.wd`. | `@include /header.wd` |
207
+ | `WD650` | Attribute is not on the accessibility whitelist | A `::: ` container header or a `:button` carries an attribute outside the accessibility whitelist (`onclick=`, `style=`, `href=`, `class=`, `data-…=`). | Keep only `role="…"`, `aria-…="…"`, and `title="…"`; style with `.class` tokens and act with `->` actions. | `::: card .note role="region" aria-label="Notes"` |
208
+ | `WD651` | Accessibility attribute has no quoted value | An attribute in a container header or a `:button` is missing its double-quoted value. | Write the value in double quotes, e.g. `role="region"`. | `::: card .note role="region" aria-label="Notes"` |
209
+
210
+ ## WD7xx — Media & embeds
211
+
212
+ | Code | Error | Cause | Fix | Example |
213
+ | --- | --- | --- | --- | --- |
214
+ | `WD701` | Malformed `:video`/`:audio` | The line carries no clip source. | Give the clip path, then optional attributes and flags. | `:video /demo.mp4 controls` |
215
+ | `WD702` | Unknown `:video`/`:audio` flag | The bare flag is outside the supported set for that element. | Use `controls`, `autoplay`, `loop`, `muted`, or (video only) `playsinline`. | `:video /demo.mp4 controls` |
216
+ | `WD703` | Unknown `:video`/`:audio` attribute | The attribute is outside the supported set for that element. | Use `poster`, `width`, `height`, or `preload` (audio takes `preload` only). | `:video /demo.mp4 controls` |
217
+ | `WD704` | Malformed `:embed` | The line carries no URL. | Give the share or watch URL, then an optional title. | `:embed https://youtu.be/dQw4w9WgXcQ title="Demo"` |
218
+ | `WD705` | Unknown `:embed` attribute | The attribute is not `title`, `height`, `aspect`, or `allow`. | Remove the attribute, or use one of the supported ones. | `:embed https://youtu.be/dQw4w9WgXcQ title="Demo"` |
219
+ | `WD706` | Unknown `:embed allow` token | An `allow="…"` permission token is outside the fixed whitelist. | Use only the listed tokens; the message names every one. | `:embed https://youtu.be/dQw4w9WgXcQ title="Demo"` |
220
+ | `WD707` | Invalid `:embed` sizing | `aspect=` is not a `<w>:<h>` ratio, or `height=` is not a plain CSS length. | Write `aspect=4:3`, or a length like `height=400px`. | `:embed https://youtu.be/dQw4w9WgXcQ title="Demo"` |
221
+
222
+ ## WD8xx — Skins & styling
223
+
224
+ | Code | Error | Cause | Fix | Example |
225
+ | --- | --- | --- | --- | --- |
226
+ | `WD801` | `scoped` is not the first line | The scoping opt-in appears somewhere other than the first meaningful line. | Move `scoped` to the top of the `.skin`, or delete it. | |
227
+ | `WD802` | Page-level declaration in a scoped skin | A declaration with no enclosing selector would write to the whole page. | Nest it under a selector, or move it to a global skin. | |
228
+ | `WD803` | Page-level selector in a scoped skin | A top-level `page`/`body`/`html`/`*`/`::selection` selector escapes the scope. | Move page-level styles to a global (non-scoped) skin. | |
229
+ | `WD804` | Malformed `@font-face` block | `@font-face` was given a name, holds a nested block, or has no descriptors — it takes plain `prop value` declaration lines only. | Write a bare `@font-face` line and indent each descriptor beneath it. | `@font-face`<br>` font-family "Newsreader"` |
230
+ | `WD805` | Invalid `@keyframes` name | The name after `@keyframes` is missing or is not a plain CSS identifier. | Name the animation with a plain identifier, e.g. `@keyframes fade-in`. | `@keyframes fade-in`<br>` to`<br>` opacity 1` |
231
+ | `WD806` | Invalid keyframe selector | A child of `@keyframes` is not `from`, `to`, or a percentage. | Use `from`, `to`, a percentage (`50%`), or a comma list of them (`50%, 75%`). | `@keyframes fade-in`<br>` to`<br>` opacity 1` |
232
+ | `WD807` | Malformed `@keyframes` body | A keyframe's declarations nest deeper, a keyframe selector has no declarations, or the block has no keyframe selectors at all. | Give each keyframe selector a flat list of declarations indented one level beneath it. | `@keyframes fade-in`<br>` to`<br>` opacity 1` |
233
+
234
+ ## WD9xx — Project, routing & CLI
235
+
236
+ | Code | Error | Cause | Fix | Example |
237
+ | --- | --- | --- | --- | --- |
238
+ | `WD901` | Duplicate route | Two source files map to the same public route (often `.md` plus `.wd`). | Rename or delete one of the files. | |
239
+ | `WD902` | Route escapes the build output | A route resolved outside `dist`, which would write outside the build. | Remove traversal segments from the route. | |
240
+ | `WD903` | Unknown init template | `darkmown init --template` names a template that does not ship. | Pick a bundled template; the message lists every available name. | |
241
+ | `WD904` | Unknown deploy target | `darkmown deploy` names a platform Darkmown does not drive. | Deploy to `vercel` or `cloudflare`. | |
242
+ | `WD905` | Deploy CLI not signed in | The platform CLI rejected the deploy with an authentication failure. | Run the platform login command, then re-run the deploy. | |
243
+ | `WD906` | Deploy command failed | The platform CLI exited non-zero for a reason other than authentication. | Read the CLI output above the error, fix the cause, and re-run. | |
244
+ | `WD907` | Unknown `ai_crawlers` policy | The home page's `ai_crawlers:` is neither `allow` nor `deny`. | Write `ai_crawlers: allow` or `ai_crawlers: deny` (absent means allow). | |
245
+ | `WD908` | Unsafe route name | A page filename produced a route containing whitespace or control characters, which would corrupt generated config files (dist/_headers, llms.txt). | Rename the file using only letters, digits, dashes, and underscores. | |
246
+ | `WD950` | Invalid `rss_limit` | The home page's `rss_limit:` is not a positive whole number of items. | Write `rss_limit: 20` (absent means 20). | |
247
+
248
+ ## Errors without a code
249
+
250
+ A handful of throws are framework invariants rather than authoring mistakes:
251
+ the expression re-parser (`src/compiler/expr-ast.js`) consumes the compiler's
252
+ own already-validated output, and the source reader (`src/compiler/reader.js`)
253
+ guards the host contract behind `compileFromMemory`. Hitting one of those is a
254
+ bug in Darkmown, not something a page can fix, so they stay uncoded and should
255
+ be reported as issues.
@@ -2,6 +2,17 @@
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.12.0 additions: perceived performance from demo feedback (2026-10-16)
6
+
7
+ - **First paint stops swapping fonts.** The font post-pass now injects per-page `<link rel="preload" as="font" crossorigin>` tags for exactly the project fonts a page's own stylesheets declare, leading with the subset woff2 when subsetting ran (the original otherwise, still a win), idempotently so dev rebuilds never duplicate a link. `darkmown serve` learned conditional requests: strong ETags and Last-Modified on every static response, RFC 7232-correct 304s, HTML at `no-cache`, fingerprinted-by-build assets at `public, max-age=3600`, dev deliberately unchanged. Together these close the demo's "fonts swap on every reload" complaint from both ends: first load preloads, reloads revalidate.
8
+ - **The default navigation animation lost its flicker.** `transitions: true` now emits a simultaneous, opacity-only 160ms cross-fade both ways (no `transform` anywhere; the directional translate was the flicker source), with keyframe names, the reduced-motion reduction, and the prerender hint unchanged. It is a visible behavior change with no markup or config delta, and pages still ship zero JS.
9
+ - **The srcset story is complete: authors can set `sizes`.** The 2.9 variant wiring emitted no `sizes`, so browsers assumed `100vw` and fetched the full-size original on desktop. The inline-attribute block now passes `sizes="…"` through on Markdown images (the build still never invents one), composing with classes, ids, and the wired srcset.
10
+
11
+ ## 2.11.0 additions: the framework speaks MCP, and every page speaks markdown (2026-10-15)
12
+
13
+ - **The language description became a protocol surface.** `darkmown mcp` is a hand-rolled, zero-dependency MCP stdio server (newline-delimited JSON-RPC 2.0, protocol 2024-11-05, no SDK, so `dependencies` stays empty). Its five tools (catalog, grammar, in-memory validate, error-code lookup, doc-section search) are thin adapters over the same drift-guarded surfaces the CLI and the test suite already pin, so an agent editing `.wd` through MCP gets the same answers as `darkmown validate`, with the shipped markdown docs riding along as `darkmown://docs/*` resources. That resource surface is why `docs/errors.md` now ships in the tarball.
14
+ - **Any page can now be read as its source.** `md_source: true` on the home page publishes each route's byte-exact markdown at `<route>.md` with a `text/markdown` alternate link in the head, a per-page opt-out, drafts excluded by the same discovery filter as everything else, and no feed or sitemap pollution. The llms-full corpus already carried full page text for agents; the `.md` sources are the always-current, per-page form of the same contract, and they cost a plain file copy at build time.
15
+
5
16
  ## 2.10.1 additions: hardening and contract repair (2026-10-14)
6
17
 
7
18
  A patch release: nothing in it moves the authoring surface, it tightens what the build publishes and repairs the error contract the 2.8.0 skin at-rules shipped without.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "2.10.1",
3
+ "version": "2.12.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -66,6 +66,7 @@
66
66
  "site",
67
67
  "!site/_/og*.png",
68
68
  "docs/cli.md",
69
+ "docs/errors.md",
69
70
  "docs/spec-alignment.md",
70
71
  "AGENTS.md",
71
72
  "README.md",