@zvndev/darkmown 2.9.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +23 -12
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +105 -6
  4. package/SECURITY.md +1 -0
  5. package/docs/cli.md +1 -1
  6. package/docs/spec-alignment.md +16 -2
  7. package/grammar/wd-directives.gbnf +1 -1
  8. package/package.json +8 -4
  9. package/site/pages/benchmarks/index.skin +67 -0
  10. package/site/pages/benchmarks/index.wd +126 -0
  11. package/site/pages/docs/index.wd +47 -2
  12. package/site/pages/index.wd +1 -0
  13. package/site/pages/playground/index.js +58 -54
  14. package/src/api-runner.js +80 -8
  15. package/src/builder.js +70 -15
  16. package/src/catalog.js +13 -4
  17. package/src/cli.js +18 -6
  18. package/src/compiler/body.js +30 -1
  19. package/src/compiler/context.js +13 -1
  20. package/src/compiler/includes.js +44 -1
  21. package/src/compiler/media.js +25 -0
  22. package/src/compiler/page.js +6 -0
  23. package/src/compiler/state.js +75 -0
  24. package/src/compiler/structure.js +272 -4
  25. package/src/errors.js +74 -3
  26. package/src/font-subset-worker.js +131 -0
  27. package/src/font-subset.js +475 -0
  28. package/src/grammar.js +3 -2
  29. package/src/headers.js +28 -1
  30. package/src/image-variant-resize.js +4 -1
  31. package/src/image-variants.js +9 -4
  32. package/src/router.js +16 -0
  33. package/src/skin.js +111 -45
  34. package/src/statics.js +22 -14
  35. package/src/templates/dashboard/README.md +3 -1
  36. package/src/templates/landing/site/pages/index.skin +7 -0
  37. package/src/templates/landing/site/pages/index.wd +10 -3
  38. package/src/templates/store/README.md +5 -2
  39. package/src/templates/store/site/_/card.wd +19 -0
  40. package/src/templates/store/site/_/products.json +6 -6
  41. package/src/templates/store/site/pages/index.skin +17 -2
  42. package/src/templates/store/site/pages/index.wd +3 -12
  43. package/types/api-runner.d.ts +20 -4
  44. package/types/builder.d.ts +25 -1
  45. package/types/catalog.d.ts +3 -2
  46. package/types/compiler/context.d.ts +28 -0
  47. package/types/compiler/includes.d.ts +23 -0
  48. package/types/compiler/structure.d.ts +53 -1
  49. package/types/errors.d.ts +6 -4
  50. package/types/font-subset.d.ts +144 -0
  51. package/types/headers.d.ts +14 -0
  52. package/types/image-variants.d.ts +7 -3
  53. package/types/router.d.ts +5 -0
  54. package/types/skin.d.ts +9 -4
@@ -0,0 +1,126 @@
1
+ ---
2
+ title: Benchmarks: a production Next.js site, rebuilt in Markdown
3
+ description: We rebuilt a live production marketing site (Next.js 16, React 19, Tailwind, Vercel) in Darkmown and measured both sides. Same copy, same images, same CMS. A tenth of the JavaScript, a third of the requests, and the two places the rebuild lost, stated plainly.
4
+ image: https://darkmown.com/__wd/media/og.png
5
+ site_url: https://darkmown.com
6
+ transitions: true
7
+ schema: TechArticle
8
+ ---
9
+
10
+ @include /nav.wd
11
+ @include /base.wd
12
+
13
+ ::: main
14
+
15
+ ::: eyebrow
16
+ Benchmarks
17
+ :::
18
+
19
+ # Same site. Same copy. A tenth of the JavaScript.
20
+
21
+ We took a live production marketing site, a multi-location dental practice running **Next.js 16, React 19, and Tailwind on Vercel**, and rebuilt it in Darkmown: same copy, same images, same fonts, same CMS content pipeline. Then we measured both. This page is every number we found, including the two categories where the rebuild is worse.
22
+
23
+ The rebuild lives in a private demo repository, so there is no source link; every figure below is the median of three measured page loads, and the [methodology](#methodology) section says exactly how we measured.
24
+
25
+ ## The headline numbers
26
+
27
+ ::: stats
28
+
29
+ ::: stat
30
+ 10x
31
+ ::: statlabel
32
+ less JavaScript on the homepage: 22 KB against 225 KB, raw bytes against compressed. Gzip to gzip it is 28x.
33
+ :::
34
+ :::
35
+
36
+ ::: stat
37
+ 3.3x
38
+ ::: statlabel
39
+ fewer requests per page: 11 to 20 across the four measured routes, against 46 to 70.
40
+ :::
41
+ :::
42
+
43
+ ::: stat
44
+ 5.6x
45
+ ::: statlabel
46
+ smaller HTML document: 56.7 KB raw against 318 KB. Compressed both ways, still 2.4x leaner.
47
+ :::
48
+ :::
49
+
50
+ ::: stat
51
+ ~6x
52
+ ::: statlabel
53
+ faster full builds: 2.7 to 3.6 seconds for 67 routes, against 16.6 s cold and 23.2 s warm.
54
+ :::
55
+ :::
56
+
57
+ :::
58
+
59
+ ## What every page pays
60
+
61
+ | | Next.js on Vercel | Darkmown rebuild |
62
+ |---|---|---|
63
+ | JavaScript per page | 225 to 227 KB compressed, 16 chunks | 18 to 23 KB total (the whole runtime is 6.6 KB gzipped) |
64
+ | Requests per page | 46 to 70 | 11 to 20 |
65
+ | Homepage HTML document | 318 KB raw, 24.9 KB brotli | 56.7 KB raw, 10.6 KB gzip |
66
+ | Third-party JavaScript | ~357 KB (Google Tag Manager, DoubleClick) | 0 B |
67
+
68
+ Two things worth saying out loud. The live site sends the **same 16 chunks, byte-identical, on all four measured routes**: whatever route-level code splitting Next.js is doing never showed up, so every page pays the full 226 KB. And the Darkmown column is raw bytes off an uncompressed preview server while the Next.js column is compressed transfer, so the real gap is wider than the table shows.
69
+
70
+ ## Route by route
71
+
72
+ ::: tablewrap
73
+
74
+ | Route | JS: Next.js | JS: Darkmown | Requests: Next.js | Requests: Darkmown | Total wire: Next.js | Total wire: Darkmown |
75
+ |---|---|---|---|---|---|---|
76
+ | `/` | 225 KB | 22 KB | 50 | 15 | 2.16 MB | 1.79 MB |
77
+ | `/services/dental-implants/` | 226 KB | 18 KB | 54 | 12 | 828 KB | 793 KB |
78
+ | `/doctors/` | 227 KB | 18 KB | 70 | 20 | 965 KB | **1.69 MB** |
79
+ | `/request-appointment/` | 226 KB | 23 KB | 46 | 11 | 784 KB | 678 KB |
80
+
81
+ :::
82
+
83
+ The doctors route is the one page where the rebuild's total wire bytes are **higher**, and it is not a rounding error: the rebuild shipped unoptimized headshots where the live site serves CDN width variants. That is the next section.
84
+
85
+ ## Build time and source size
86
+
87
+ Measured on the same machine, 67 routes on both sides:
88
+
89
+ | | Next.js 16 | Darkmown |
90
+ |---|---|---|
91
+ | Cold build | 16.6 s | 2.7 to 3.6 s |
92
+ | Warm build | 23.2 s | 2.7 to 3.6 s (there is no build cache) |
93
+ | CPU time per build | ~30 s | ~0.6 s |
94
+
95
+ | | Next.js project | Darkmown rebuild |
96
+ |---|---|---|
97
+ | Maintained source | ~33,000 lines (24.5k code + 8.4k CMS JSON) | 17,300 lines total |
98
+ | Project on disk | 39 MB | 5.5 MB |
99
+ | `node_modules` | 932 MB | 15 MB (62x smaller) |
100
+
101
+ ## Where Darkmown loses
102
+
103
+ Both of these were real losses on measured bytes, not hypotheticals, and both shaped what shipped next.
104
+
105
+ - **Fonts, badly.** The rebuild served three raw variable TTFs: 1.07 MB on the homepage, against `next/font`'s 158 KB of subset woff2. Fonts were the single largest byte category on every rebuilt page. The 2.8 documentation shipped a font recipe (convert to woff2, subset to the glyphs used, `font-display swap`, preload the critical face), and 2.10 closes the gap outright: with the optional toolchain installed, the build subsets project-local fonts to the site's charset automatically. Measured on these same demo fonts, the 1.07 MB of TTFs became 288 KB of woff2.
106
+ - **Photos.** The doctors page shipped 1,045 KB of headshots against the live site's 147 KB of CDN width variants. Darkmown 2.8 added the `@width` convention (drop in `photo@640.jpg` and the build wires the `srcset`), and 2.9 generates those variants at build time when sharp is installed. Run the same rebuild today and this category flips.
107
+ - **A shared cost neither side avoids:** both sites load the same ~594 KB hero video on the homepage. No edge for anyone there.
108
+
109
+ ## Methodology
110
+
111
+ - Measured on 2026-10-13 with Chromium via Playwright: 1280x900 desktop viewport, cache disabled, three fresh loads per URL, median reported. Wire bytes are CDP `encodedDataLength`, which is compressed transfer size plus response headers.
112
+ - The live site runs on Vercel's edge network; the rebuild was measured from a local static build (`darkmown serve`). Local timings reflect zero network latency, so **we do not compare timing columns at all**. Bytes and request counts are the fair comparison.
113
+ - The preview server did not compress responses in this run, so the rebuild's text wire bytes overstate what the same `dist/` transfers behind a compressing host: its homepage HTML is 57.4 KB on the wire but 10.6 KB gzipped, and any Vercel or Cloudflare deploy of the same output gets brotli and edge caching for free.
114
+ - Four routes measured out of 67. The content pipeline is identical on both sides: the same Capa CMS snapshot drives both sites, and the Darkmown sync regenerates its 41 managed pages byte-identically in 0.05 s.
115
+ - **What Next.js does that Darkmown does not:** server rendering, streaming, per-request personalization, edge caching, an image CDN, and automatic font subsetting. This site exercises none of the dynamic ones; its pages are the same for every visitor, which is exactly the case a static build exists for. Where Next.js's automatic asset optimization (fonts, responsive images) would win, it did win, and that is the section above.
116
+ - Whether a production dental site keeps Google Tag Manager is a business decision, not a framework property. The rebuild demonstrates the site works without it, and the ~357 KB saving is stated as such.
117
+
118
+ ::: bench-cta
119
+ [Read the docs](/docs/){.btn .primary}
120
+ [Try Darkmown in your browser](/playground/){.btn}
121
+ [Darkmown vs. Markdoc](/vs/markdoc/){.btn}
122
+ :::
123
+
124
+ :::
125
+
126
+ @include /footer.wd
@@ -60,6 +60,7 @@ Darkmown is a Markdown-native framework for mostly-static sites with tiny reacti
60
60
  - [Compile error codes](#compile-error-codes)
61
61
  - [CLI reference](#cli-reference)
62
62
  - [Editor support](#editor-support)
63
+ - [Benchmarks](#benchmarks)
63
64
  - [Accessibility](#accessibility)
64
65
  - [Security](#security)
65
66
  - [Limits](#limits)
@@ -116,6 +117,44 @@ A directive is a whole line, written flush against the left margin. Four rules c
116
117
  - `with title={ feature.title }` passes a value already in scope — including whole objects.
117
118
  - Includes inside a loop inherit the loop value automatically.
118
119
 
120
+ An include declares fallbacks for its `with` arguments in its own frontmatter with `defaults: [title="Untitled", cta="Learn more"]`. The caller's `with` value always wins; the default fills in when the caller is silent; an argument with neither renders as literal `{ title }` text, as before. Values are strings (frontmatter is string-typed), entries split on top-level commas only (so `cta="Learn more, today"` works), and a malformed `defaults:` is `[WD613]`.
121
+
122
+ The block form turns an include into a wrapper component. The lines between the call and `@endinclude` compile once in the **caller's** scope (its `:state`, and the loop item inside a `@loop`) and land wherever the include puts a standalone `{ children }` line:
123
+
124
+ ```wd
125
+ @include /card.wd with title="Pricing"
126
+
127
+ Everything in **this paragraph** is the card body.
128
+
129
+ @endinclude
130
+ ```
131
+
132
+ with `site/_/card.wd`:
133
+
134
+ ```wd
135
+ ---
136
+ defaults: [title="Untitled"]
137
+ ---
138
+
139
+ ::: card
140
+ ## { title }
141
+
142
+ { children }
143
+ :::
144
+ ```
145
+
146
+ The slot is a standalone line only (mid-paragraph or fenced `{ children }` stays literal; on a top-level page it is literal too; a `.md` include cannot host one). Called without a block, the slot renders empty; a block given to an include that never uses `{ children }` is dropped with a build warning. `@endinclude` closes the nearest preceding unpaired `@include`, so an inline `@include` written between opener and closer consumes the pairing; one level of block nesting is supported (deeper is `[WD616]`), and a stray closer is `[WD614]`. There is deliberately no unclosed-block error: an unpaired opener is the inline form, and the inline form must stay legal. Per-row components in a loop are the payoff, and stay zero-JS over a static source:
147
+
148
+ ```wd
149
+ @loop /products.json into product
150
+ @include /card.wd with title={ product.name }
151
+
152
+ Price: { product.price | money }
153
+
154
+ @endinclude
155
+ @endloop
156
+ ```
157
+
119
158
  ## Loops
120
159
 
121
160
  `@loop <things> into <thing>` is the only loop. The body is normal Markdown (or more directives) and repeats once per row:
@@ -861,6 +900,8 @@ Two CSS at-rules compile as self-contained blocks in a `.skin`, because their in
861
900
 
862
901
  `@font-face` takes plain declarations, several blocks per skin allowed. `@keyframes <name>` requires a plain identifier; each indented child is a keyframe selector (`from`, `to`, a percentage, or a comma list) with its declarations one level deeper, and anything else is a compile error, so a keyframe selector is never misread as a declaration. Aliases and `$token` refs work inside both, both can nest under an `@media` line (the home for `prefers-reduced-motion` animation guards), and both stay **global** in a `scoped` skin, same as `tokens` blocks.
863
902
 
903
+ **Build-time font subsetting.** With the wasm toolchain installed (`npm i -D harfbuzzjs wawoff2`, both optional dependencies), a post-pass subsets every project-local `.ttf`/`.otf`/`.woff` referenced by an `@font-face src url(...)` to the site's character set (every code point in the emitted HTML, plus a base set of ASCII, Latin-1, punctuation, arrows, and ★ ☆ ✓ ✕ ✗), encodes it as woff2, emits `<name>-subset.woff2` beside the original, and rewrites the dist CSS so `src:` lists the subset first; the source `.skin` is never touched, and layout features and variable axes survive. Without the toolchain the build is unchanged plus one hint per build, a font that fails to parse ships as-is with its own hint, and the build never fails over a font. Subsets cache in `node_modules/.cache/darkmown-fonts/` keyed by font and charset hashes, and the CSS rewrite is idempotent, so incremental dev builds are safe. The build summary reports `fonts (3 subsetted, 0 cached)` when the post-pass did work.
904
+
864
905
  ## Inline attributes
865
906
 
866
907
  A trailing `{.class .class #id}` attaches classes or an id to the inline element directly before it, most often to style a link as a button without a wrapper:
@@ -1010,6 +1051,10 @@ The `darkmown` command (installed by `npx @zvndev/darkmown`) is the whole tool
1010
1051
 
1011
1052
  A [VS Code extension](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 instead of broken Markdown. Build a `.vsix` from the framework repo and install it with `code --install-extension`; a Marketplace listing is pending.
1012
1053
 
1054
+ ## Benchmarks
1055
+
1056
+ A live production marketing site (Next.js 16, React 19, Tailwind, on Vercel) was rebuilt in Darkmown with the same copy, images, and CMS content pipeline, then both sides were measured (median of three loads, four matched routes): 225-227 KB of compressed JavaScript on every Next.js page vs 18-23 KB total in Darkmown, 46-70 requests vs 11-20, and full builds of 16.6 s cold / 23.2 s warm vs 2.7-3.6 s for all 67 routes. The rebuild lost two categories honestly: raw TTF fonts (1.07 MB vs 158 KB of subset woff2) and unoptimized photos (1,045 KB vs 147 KB of CDN width variants), both addressed by the 2.8/2.9 font recipe and build-time responsive images. The full tables, the per-route breakdown, and the methodology are at [/benchmarks/](/benchmarks/).
1057
+
1013
1058
  ## Accessibility
1014
1059
 
1015
1060
  Landmark and announcement basics are baked in at build time, so they cost zero JavaScript and a static page stays static:
@@ -1042,7 +1087,7 @@ Builds emit security response headers, so a deployed site gets sane defaults wit
1042
1087
  - **`X-Content-Type-Options: nosniff`**, a **`Referrer-Policy`**, and a **`frame-ancestors`** directive (clickjacking protection) on every page.
1043
1088
  - A raw inline `<script>` you write into an `html: true` page is blocked by that policy. Put it in a colocated `.js` file (same-origin, allowed by `'self'`) or widen `script-src` deliberately.
1044
1089
  - Calling another host with `:fetch` or `:form action=`? Widen `connect-src` (and `form-action` for a native form POST) in your deploy config. It is not derived from your page sources.
1045
- - The two embed origins (`youtube-nocookie.com`, `player.vimeo.com`) and `media-src 'self' https:` are pre-authorized, so `:embed` and remote media work out of the box.
1090
+ - The two built-in embed origins (`youtube-nocookie.com`, `player.vimeo.com`) are pre-authorized on every page, and any other `:embed` host is unioned into `frame-src` for exactly the pages that embed it, so a third-party booking or checkout iframe works under the shipped headers. `media-src 'self' https:` is unchanged, so `:embed` and remote media work out of the box. The per-route union ships in Cloudflare's `dist/_headers` and is honored by `darkmown serve`; Vercel reads `vercel.json` before the build, so on Vercel you widen `frame-src` in your own `vercel.json` for a non-YouTube/Vimeo host.
1046
1091
 
1047
1092
  ## Limits
1048
1093
 
@@ -1068,7 +1113,7 @@ Darkmown is small on purpose. Here is what it deliberately refuses and what it g
1068
1113
  - **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.
1069
1114
  - **No i18n beyond `lang:`.** No `hreflang`, no message catalog, no ambient locale, no locale routing. A multilingual site means duplicating the tree per locale.
1070
1115
  - **Every framework asset URL is absolute** (`/__wd/...`), so a Darkmown site cannot be mounted at a subpath. It is all-or-nothing per origin.
1071
- - **Includes are macros, not components.** No children, no slots, no default arguments, and a missing argument renders `{ title }` literally rather than erroring.
1116
+ - **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.
1072
1117
  - **No `darkmown test`.** There is no built-in way to assert your own site's behavior. Use [`compileFromMemory`](#programmatic-compile-compilefrommemory) or a browser test runner.
1073
1118
 
1074
1119
  **Migrating out is easy, and that is on purpose.** `dist/` is portable static HTML plus one runtime file of ~6.6 KB gzipped. No server to port, no proprietary component format, no framework baked into the output. Point any host at the folder, or hand it to whatever you move to next, and the site keeps working.
@@ -188,6 +188,7 @@ The echo endpoint is only live on the deployed site: { reply_error }
188
188
  [Deploy to Vercel](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown){.btn .primary}
189
189
  [Read the docs](/docs/){.btn}
190
190
  [How it compares to Markdoc](/vs/markdoc/){.btn}
191
+ [Benchmarks vs. a real Next.js site](/benchmarks/){.btn}
191
192
  :::
192
193
 
193
194
  :::