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