@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Benchmarks: a production Next.js site, rebuilt in Markdown
|
|
3
|
+
description: We rebuilt a live production marketing site (Next.js 16, React 19, Tailwind, Vercel) in Darkmown and measured both sides. Same copy, same images, same CMS. A tenth of the JavaScript, a third of the requests, and the two places the rebuild lost, stated plainly.
|
|
4
|
+
image: https://darkmown.com/__wd/media/og.png
|
|
5
|
+
site_url: https://darkmown.com
|
|
6
|
+
transitions: true
|
|
7
|
+
schema: TechArticle
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
@include /nav.wd
|
|
11
|
+
@include /base.wd
|
|
12
|
+
|
|
13
|
+
::: main
|
|
14
|
+
|
|
15
|
+
::: eyebrow
|
|
16
|
+
Benchmarks
|
|
17
|
+
:::
|
|
18
|
+
|
|
19
|
+
# Same site. Same copy. A tenth of the JavaScript.
|
|
20
|
+
|
|
21
|
+
We took a live production marketing site, a multi-location dental practice running **Next.js 16, React 19, and Tailwind on Vercel**, and rebuilt it in Darkmown: same copy, same images, same fonts, same CMS content pipeline. Then we measured both. This page is every number we found, including the two categories where the rebuild is worse.
|
|
22
|
+
|
|
23
|
+
The rebuild lives in a private demo repository, so there is no source link; every figure below is the median of three measured page loads, and the [methodology](#methodology) section says exactly how we measured.
|
|
24
|
+
|
|
25
|
+
## The headline numbers
|
|
26
|
+
|
|
27
|
+
::: stats
|
|
28
|
+
|
|
29
|
+
::: stat
|
|
30
|
+
10x
|
|
31
|
+
::: statlabel
|
|
32
|
+
less JavaScript on the homepage: 22 KB against 225 KB, raw bytes against compressed. Gzip to gzip it is 28x.
|
|
33
|
+
:::
|
|
34
|
+
:::
|
|
35
|
+
|
|
36
|
+
::: stat
|
|
37
|
+
3.3x
|
|
38
|
+
::: statlabel
|
|
39
|
+
fewer requests per page: 11 to 20 across the four measured routes, against 46 to 70.
|
|
40
|
+
:::
|
|
41
|
+
:::
|
|
42
|
+
|
|
43
|
+
::: stat
|
|
44
|
+
5.6x
|
|
45
|
+
::: statlabel
|
|
46
|
+
smaller HTML document: 56.7 KB raw against 318 KB. Compressed both ways, still 2.4x leaner.
|
|
47
|
+
:::
|
|
48
|
+
:::
|
|
49
|
+
|
|
50
|
+
::: stat
|
|
51
|
+
~6x
|
|
52
|
+
::: statlabel
|
|
53
|
+
faster full builds: 2.7 to 3.6 seconds for 67 routes, against 16.6 s cold and 23.2 s warm.
|
|
54
|
+
:::
|
|
55
|
+
:::
|
|
56
|
+
|
|
57
|
+
:::
|
|
58
|
+
|
|
59
|
+
## What every page pays
|
|
60
|
+
|
|
61
|
+
| | Next.js on Vercel | Darkmown rebuild |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| JavaScript per page | 225 to 227 KB compressed, 16 chunks | 18 to 23 KB total (the whole runtime is 6.6 KB gzipped) |
|
|
64
|
+
| Requests per page | 46 to 70 | 11 to 20 |
|
|
65
|
+
| Homepage HTML document | 318 KB raw, 24.9 KB brotli | 56.7 KB raw, 10.6 KB gzip |
|
|
66
|
+
| Third-party JavaScript | ~357 KB (Google Tag Manager, DoubleClick) | 0 B |
|
|
67
|
+
|
|
68
|
+
Two things worth saying out loud. The live site sends the **same 16 chunks, byte-identical, on all four measured routes**: whatever route-level code splitting Next.js is doing never showed up, so every page pays the full 226 KB. And the Darkmown column is raw bytes off an uncompressed preview server while the Next.js column is compressed transfer, so the real gap is wider than the table shows.
|
|
69
|
+
|
|
70
|
+
## Route by route
|
|
71
|
+
|
|
72
|
+
::: tablewrap
|
|
73
|
+
|
|
74
|
+
| Route | JS: Next.js | JS: Darkmown | Requests: Next.js | Requests: Darkmown | Total wire: Next.js | Total wire: Darkmown |
|
|
75
|
+
|---|---|---|---|---|---|---|
|
|
76
|
+
| `/` | 225 KB | 22 KB | 50 | 15 | 2.16 MB | 1.79 MB |
|
|
77
|
+
| `/services/dental-implants/` | 226 KB | 18 KB | 54 | 12 | 828 KB | 793 KB |
|
|
78
|
+
| `/doctors/` | 227 KB | 18 KB | 70 | 20 | 965 KB | **1.69 MB** |
|
|
79
|
+
| `/request-appointment/` | 226 KB | 23 KB | 46 | 11 | 784 KB | 678 KB |
|
|
80
|
+
|
|
81
|
+
:::
|
|
82
|
+
|
|
83
|
+
The doctors route is the one page where the rebuild's total wire bytes are **higher**, and it is not a rounding error: the rebuild shipped unoptimized headshots where the live site serves CDN width variants. That is the next section.
|
|
84
|
+
|
|
85
|
+
## Build time and source size
|
|
86
|
+
|
|
87
|
+
Measured on the same machine, 67 routes on both sides:
|
|
88
|
+
|
|
89
|
+
| | Next.js 16 | Darkmown |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| Cold build | 16.6 s | 2.7 to 3.6 s |
|
|
92
|
+
| Warm build | 23.2 s | 2.7 to 3.6 s (there is no build cache) |
|
|
93
|
+
| CPU time per build | ~30 s | ~0.6 s |
|
|
94
|
+
|
|
95
|
+
| | Next.js project | Darkmown rebuild |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| Maintained source | ~33,000 lines (24.5k code + 8.4k CMS JSON) | 17,300 lines total |
|
|
98
|
+
| Project on disk | 39 MB | 5.5 MB |
|
|
99
|
+
| `node_modules` | 932 MB | 15 MB (62x smaller) |
|
|
100
|
+
|
|
101
|
+
## Where Darkmown loses
|
|
102
|
+
|
|
103
|
+
Both of these were real losses on measured bytes, not hypotheticals, and both shaped what shipped next.
|
|
104
|
+
|
|
105
|
+
- **Fonts, badly.** The rebuild served three raw variable TTFs: 1.07 MB on the homepage, against `next/font`'s 158 KB of subset woff2. Fonts were the single largest byte category on every rebuilt page. The 2.8 documentation now ships a font recipe (convert to woff2, subset to the glyphs used, `font-display swap`, preload the critical face), and build-time subsetting is on the roadmap.
|
|
106
|
+
- **Photos.** The doctors page shipped 1,045 KB of headshots against the live site's 147 KB of CDN width variants. Darkmown 2.8 added the `@width` convention (drop in `photo@640.jpg` and the build wires the `srcset`), and 2.9 generates those variants at build time when sharp is installed. Run the same rebuild today and this category flips.
|
|
107
|
+
- **A shared cost neither side avoids:** both sites load the same ~594 KB hero video on the homepage. No edge for anyone there.
|
|
108
|
+
|
|
109
|
+
## Methodology
|
|
110
|
+
|
|
111
|
+
- Measured on 2026-10-13 with Chromium via Playwright: 1280x900 desktop viewport, cache disabled, three fresh loads per URL, median reported. Wire bytes are CDP `encodedDataLength`, which is compressed transfer size plus response headers.
|
|
112
|
+
- The live site runs on Vercel's edge network; the rebuild was measured from a local static build (`darkmown serve`). Local timings reflect zero network latency, so **we do not compare timing columns at all**. Bytes and request counts are the fair comparison.
|
|
113
|
+
- The preview server did not compress responses in this run, so the rebuild's text wire bytes overstate what the same `dist/` transfers behind a compressing host: its homepage HTML is 57.4 KB on the wire but 10.6 KB gzipped, and any Vercel or Cloudflare deploy of the same output gets brotli and edge caching for free.
|
|
114
|
+
- Four routes measured out of 67. The content pipeline is identical on both sides: the same Capa CMS snapshot drives both sites, and the Darkmown sync regenerates its 41 managed pages byte-identically in 0.05 s.
|
|
115
|
+
- **What Next.js does that Darkmown does not:** server rendering, streaming, per-request personalization, edge caching, an image CDN, and automatic font subsetting. This site exercises none of the dynamic ones; its pages are the same for every visitor, which is exactly the case a static build exists for. Where Next.js's automatic asset optimization (fonts, responsive images) would win, it did win, and that is the section above.
|
|
116
|
+
- Whether a production dental site keeps Google Tag Manager is a business decision, not a framework property. The rebuild demonstrates the site works without it, and the ~357 KB saving is stated as such.
|
|
117
|
+
|
|
118
|
+
::: bench-cta
|
|
119
|
+
[Read the docs](/docs/){.btn .primary}
|
|
120
|
+
[Try Darkmown in your browser](/playground/){.btn}
|
|
121
|
+
[Darkmown vs. Markdoc](/vs/markdoc/){.btn}
|
|
122
|
+
:::
|
|
123
|
+
|
|
124
|
+
:::
|
|
125
|
+
|
|
126
|
+
@include /footer.wd
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -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:
|
|
@@ -441,7 +480,7 @@ Add `transitions: true` to a page's frontmatter for **instant, flash-free naviga
|
|
|
441
480
|
|
|
442
481
|
You don't size images by hand. Every `<img>` the compiler emits gets its intrinsic `width`/`height` read from the source file (so the page doesn't reflow as images decode), `decoding="async"`, and a priority split — the first image stays eager with `fetchpriority="high"`, the rest `loading="lazy"`. Anything you set yourself wins. The compiler measures, it doesn't resize, so keep source images web-sized.
|
|
443
482
|
|
|
444
|
-
**Responsive variants.**
|
|
483
|
+
**Responsive variants, generated at build time.** With sharp installed (`npm i -D sharp`, an optional dependency; builds work without it), every raster photo in `site/pages` over 700px wide is resized to 640w and 1280w siblings in `dist/` (only widths below the intrinsic size, never upscaled, EXIF orientation applied), and the Markdown image gains a `srcset` with the original kept as `src`. Export `name@<width>.ext` yourself and your file wins that width verbatim, measured from its own header; generation fills only the widths you left uncovered, and other sizes are a hand export away (the compiler probes 64 through 3840). Skipped silently: small images, `@width` files, hidden or 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 are cached in `node_modules/.cache/darkmown-images/` by content hash + width + format, so second builds and dev rebuilds do no resizing for unchanged images, and the build summary reports `image variants (N generated, M cached)` when variants materialized. `sizes` is never emitted (write raw `<img srcset sizes>` on an `html: true` page for that); bound (``), remote, `data:`, query/hash, page-relative, and extensionless srcs are never wired, and neither are raw-HTML `<img>` tags. A `.md` page gets the hardening but no srcset.
|
|
445
484
|
|
|
446
485
|
## Static assets
|
|
447
486
|
|
|
@@ -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:`
|
|
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.**
|
|
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.
|
package/site/pages/index.wd
CHANGED
|
@@ -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
|
:::
|
package/src/builder.js
CHANGED
|
@@ -19,8 +19,10 @@ import {
|
|
|
19
19
|
sitemapDate,
|
|
20
20
|
sitemapDocuments
|
|
21
21
|
} from "./feeds.js";
|
|
22
|
-
import {
|
|
22
|
+
import { subsetFonts } from "./font-subset.js";
|
|
23
|
+
import { BASE_SECURITY_HEADERS, REACTIVE_CSP, routeCsp } from "./headers.js";
|
|
23
24
|
import { HIGHLIGHT_CSS } from "./highlight.js";
|
|
25
|
+
import { generateImageVariants } from "./image-variants.js";
|
|
24
26
|
import { discoverRoutes, outputPathForRoute } from "./router.js";
|
|
25
27
|
import { compileSkin, scopeIdFor } from "./skin.js";
|
|
26
28
|
|
|
@@ -36,7 +38,10 @@ import { compileSkin, scopeIdFor } from "./skin.js";
|
|
|
36
38
|
* @typedef {object} RouteManifestEntry
|
|
37
39
|
* @property {string} route Public route path.
|
|
38
40
|
* @property {string} file Source file path, relative to cwd, POSIX-separated.
|
|
39
|
-
* @property {{ skins: string[], scripts: string[], runtime: boolean }} assets
|
|
41
|
+
* @property {{ skins: string[], scripts: string[], runtime: boolean, embedHosts?: string[] }} assets
|
|
42
|
+
* `embedHosts` is present only when the page's `:embed`s frame at least one
|
|
43
|
+
* source the base CSP does not already cover — absent keeps a no-embed
|
|
44
|
+
* site's routes.json byte-identical.
|
|
40
45
|
*/
|
|
41
46
|
|
|
42
47
|
/**
|
|
@@ -59,8 +64,10 @@ import { compileSkin, scopeIdFor } from "./skin.js";
|
|
|
59
64
|
export const DEP_MAP_FILE = ".wd-dev-deps.json";
|
|
60
65
|
|
|
61
66
|
// Bumped whenever the map's shape changes, so a stale map from an older
|
|
62
|
-
// Darkmown falls back to a full rebuild instead of being misread.
|
|
63
|
-
|
|
67
|
+
// Darkmown falls back to a full rebuild instead of being misread. (3: manifest
|
|
68
|
+
// entries gained `assets.embedHosts` — a v2 map's entries would silently drop
|
|
69
|
+
// them from a reassembled `_headers`/`routes.json`.)
|
|
70
|
+
const DEP_MAP_VERSION = 3;
|
|
64
71
|
|
|
65
72
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
66
73
|
|
|
@@ -105,6 +112,12 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
105
112
|
* @property {RouteManifestEntry[]} routes
|
|
106
113
|
* @property {string} distRoot
|
|
107
114
|
* @property {{ sitemap: number | null, rss: number | null }} feeds
|
|
115
|
+
* @property {{ generated: number, cached: number, unavailable: boolean }} images
|
|
116
|
+
* Responsive-variant pre-pass counts (2.9.0): freshly resized, cache-served,
|
|
117
|
+
* and whether sharp could not be loaded at all.
|
|
118
|
+
* @property {{ subsetted: number, cached: number, unavailable: boolean }} fonts
|
|
119
|
+
* Font-subset post-pass counts (2.10.0): freshly subsetted, cache-served, and
|
|
120
|
+
* whether harfbuzzjs/wawoff2 could not be loaded at all.
|
|
108
121
|
* @property {{ changed: string[], rebuilt: string[], total: number }} [incremental]
|
|
109
122
|
*/
|
|
110
123
|
|
|
@@ -119,6 +132,12 @@ function buildFull(cwd, paths, options) {
|
|
|
119
132
|
fs.rmSync(paths.distRoot, { recursive: true, force: true });
|
|
120
133
|
fs.mkdirSync(paths.distRoot, { recursive: true });
|
|
121
134
|
emitShelfAssets(paths);
|
|
135
|
+
// Responsive image variants (2.9.0): resize the large raster photos in
|
|
136
|
+
// site/pages into dist and collect the variant map BEFORE any route compiles,
|
|
137
|
+
// so the srcset wiring can reference what was generated. With the optional
|
|
138
|
+
// sharp dependency absent (or everything cache-served) this is cheap and
|
|
139
|
+
// never fails the build.
|
|
140
|
+
const images = generateImageVariants(paths, { quiet: options.quietFeedHints });
|
|
122
141
|
const routes = discoverRoutes(paths.routesRoot, { includeDrafts: options.includeDrafts });
|
|
123
142
|
// Build the collection index from the (post-draft-filter) routes — every
|
|
124
143
|
// site/pages/<name>/ subdirectory's entries become queryable rows a bare-name
|
|
@@ -152,7 +171,17 @@ function buildFull(cwd, paths, options) {
|
|
|
152
171
|
const mapRoutes = {};
|
|
153
172
|
|
|
154
173
|
for (const route of routes) {
|
|
155
|
-
const built = buildRoute(
|
|
174
|
+
const built = buildRoute(
|
|
175
|
+
route,
|
|
176
|
+
cwd,
|
|
177
|
+
paths,
|
|
178
|
+
collections,
|
|
179
|
+
feedLink,
|
|
180
|
+
warned,
|
|
181
|
+
identity,
|
|
182
|
+
titles,
|
|
183
|
+
images.variants
|
|
184
|
+
);
|
|
156
185
|
manifest.push(...built.mapEntry.pages);
|
|
157
186
|
if (built.notFoundHtml !== undefined) notFoundHtml = built.notFoundHtml;
|
|
158
187
|
for (const entry of built.mapEntry.pages) {
|
|
@@ -180,6 +209,13 @@ function buildFull(cwd, paths, options) {
|
|
|
180
209
|
// sees every emitted route/framework file already on disk.
|
|
181
210
|
emitPageAssets(paths);
|
|
182
211
|
|
|
212
|
+
// Font subsetting (2.10.0): a post-pass over the emitted dist — every local
|
|
213
|
+
// font an emitted `@font-face` references is subsetted to the site's charset
|
|
214
|
+
// as `<name>-subset.woff2` beside the original, and the dist CSS is rewritten
|
|
215
|
+
// to lead with it. With the optional harfbuzzjs/wawoff2 toolchain absent (or
|
|
216
|
+
// everything cache-served) this is cheap and never fails the build.
|
|
217
|
+
const fonts = subsetFonts(paths, { quiet: options.quietFeedHints });
|
|
218
|
+
|
|
183
219
|
// Surface api/ routing footguns on every build (target-independent): two files
|
|
184
220
|
// that resolve to the same /api path mean one is silently unreachable.
|
|
185
221
|
const apiDir = path.join(cwd, "api");
|
|
@@ -192,7 +228,13 @@ function buildFull(cwd, paths, options) {
|
|
|
192
228
|
|
|
193
229
|
if (options.depMap) writeDepMap(paths, feedLink, siteInputs(identity, titles), mapRoutes);
|
|
194
230
|
|
|
195
|
-
return {
|
|
231
|
+
return {
|
|
232
|
+
routes: manifest,
|
|
233
|
+
distRoot: paths.distRoot,
|
|
234
|
+
feeds,
|
|
235
|
+
images: { generated: images.generated, cached: images.cached, unavailable: images.unavailable },
|
|
236
|
+
fonts: { subsetted: fonts.subsetted, cached: fonts.cached, unavailable: fonts.unavailable }
|
|
237
|
+
};
|
|
196
238
|
}
|
|
197
239
|
|
|
198
240
|
/**
|
|
@@ -284,13 +326,30 @@ function buildIncremental(cwd, paths, options, changed) {
|
|
|
284
326
|
}
|
|
285
327
|
}
|
|
286
328
|
|
|
329
|
+
// Rebuilt routes must compile byte-identically to a full rebuild, so the
|
|
330
|
+
// generated-variant map the srcset wiring consults is recomputed here too.
|
|
331
|
+
// An image CHANGE never reaches this point (no dependency graph tracks
|
|
332
|
+
// images, so it already forced a full rebuild above), which makes this scan
|
|
333
|
+
// all cache hits: it refreshes dist copies and the map, and spawns nothing.
|
|
334
|
+
const images = generateImageVariants(paths, { quiet: options.quietFeedHints });
|
|
335
|
+
|
|
287
336
|
const byRoute = new Map(routes.map((route) => [route.route, route]));
|
|
288
337
|
/** @type {Set<string>} */
|
|
289
338
|
const warned = new Set();
|
|
290
339
|
const rebuilt = [...affected].sort();
|
|
291
340
|
for (const routePath of rebuilt) {
|
|
292
341
|
const route = /** @type {Route} */ (byRoute.get(routePath));
|
|
293
|
-
const built = buildRoute(
|
|
342
|
+
const built = buildRoute(
|
|
343
|
+
route,
|
|
344
|
+
cwd,
|
|
345
|
+
paths,
|
|
346
|
+
collections,
|
|
347
|
+
feedLink,
|
|
348
|
+
warned,
|
|
349
|
+
identity,
|
|
350
|
+
titles,
|
|
351
|
+
images.variants
|
|
352
|
+
);
|
|
294
353
|
// A shrunk pagination leaves stale `/page/N/` outputs behind — remove any
|
|
295
354
|
// previously-emitted page this rebuild no longer produces.
|
|
296
355
|
const kept = new Set(built.mapEntry.pages.map((entry) => entry.route));
|
|
@@ -322,10 +381,17 @@ function buildIncremental(cwd, paths, options, changed) {
|
|
|
322
381
|
const feeds = emitFeeds([...routes, ...extraRoutes], identity, paths, options.quietFeedHints);
|
|
323
382
|
writeDepMap(paths, feedLink, siteInputs(identity, titles), map.routes);
|
|
324
383
|
|
|
384
|
+
// The font post-pass re-scans the whole emitted dist (all cache hits when
|
|
385
|
+
// neither the fonts nor the content changed), so a partially rebuilt route
|
|
386
|
+
// leaves the rewritten CSS and the subset files whole-site consistent.
|
|
387
|
+
const fonts = subsetFonts(paths, { quiet: options.quietFeedHints });
|
|
388
|
+
|
|
325
389
|
return {
|
|
326
390
|
routes: manifest,
|
|
327
391
|
distRoot: paths.distRoot,
|
|
328
392
|
feeds,
|
|
393
|
+
images: { generated: images.generated, cached: images.cached, unavailable: images.unavailable },
|
|
394
|
+
fonts: { subsetted: fonts.subsetted, cached: fonts.cached, unavailable: fonts.unavailable },
|
|
329
395
|
incremental: { changed, rebuilt, total: routes.length }
|
|
330
396
|
};
|
|
331
397
|
}
|
|
@@ -344,10 +410,30 @@ function buildIncremental(cwd, paths, options, changed) {
|
|
|
344
410
|
* @param {SiteIdentity} identity Resolved site identity (its `siteUrl` is what
|
|
345
411
|
* makes a canonical URL possible).
|
|
346
412
|
* @param {Map<string, string>} titles Route path → page title, for breadcrumbs.
|
|
413
|
+
* @param {Map<string, number[]>} imageVariants Generated responsive variants
|
|
414
|
+
* (original's absolute source path → widths), threaded into each compile.
|
|
347
415
|
* @returns {{ mapEntry: DepMapEntry, notFoundHtml: string | undefined }}
|
|
348
416
|
*/
|
|
349
|
-
function buildRoute(
|
|
350
|
-
|
|
417
|
+
function buildRoute(
|
|
418
|
+
route,
|
|
419
|
+
cwd,
|
|
420
|
+
paths,
|
|
421
|
+
collections,
|
|
422
|
+
feedLink,
|
|
423
|
+
warned,
|
|
424
|
+
identity,
|
|
425
|
+
titles,
|
|
426
|
+
imageVariants
|
|
427
|
+
) {
|
|
428
|
+
const compiled = compileRoutePages(
|
|
429
|
+
route,
|
|
430
|
+
paths,
|
|
431
|
+
collections,
|
|
432
|
+
feedLink,
|
|
433
|
+
identity,
|
|
434
|
+
titles,
|
|
435
|
+
imageVariants
|
|
436
|
+
);
|
|
351
437
|
/** @type {RouteManifestEntry[]} */
|
|
352
438
|
const entries = [];
|
|
353
439
|
/** @type {string | undefined} */
|
|
@@ -367,6 +453,10 @@ function buildRoute(route, cwd, paths, collections, feedLink, warned, identity,
|
|
|
367
453
|
emitBehaviors(page.assets, paths);
|
|
368
454
|
if (page.assets.hasCode) emitHighlight(paths);
|
|
369
455
|
const behaviorSrcs = [...page.assets.behaviors].map((name) => `/__wd/behaviors/${name}.js`);
|
|
456
|
+
// The page's :embed frame sources (origins, or 'self'), sorted for a stable
|
|
457
|
+
// manifest; omitted entirely when empty so a no-embed site's routes.json is
|
|
458
|
+
// byte-identical to before.
|
|
459
|
+
const embedHosts = [...page.assets.embedHosts].sort();
|
|
370
460
|
entries.push({
|
|
371
461
|
route: page.route,
|
|
372
462
|
file: relPosix(cwd, route.file),
|
|
@@ -377,7 +467,8 @@ function buildRoute(route, cwd, paths, collections, feedLink, warned, identity,
|
|
|
377
467
|
...behaviorSrcs,
|
|
378
468
|
...page.assets.scripts
|
|
379
469
|
],
|
|
380
|
-
runtime: page.assets.runtime
|
|
470
|
+
runtime: page.assets.runtime,
|
|
471
|
+
...(embedHosts.length > 0 ? { embedHosts } : {})
|
|
381
472
|
}
|
|
382
473
|
});
|
|
383
474
|
}
|
|
@@ -645,7 +736,10 @@ function collectionMemberOf(file) {
|
|
|
645
736
|
* this point — in practice `@loop` JSON data files, published under
|
|
646
737
|
* `/__wd/data/` (shelf) or beside their page (site/pages). Page/skin/script
|
|
647
738
|
* sources have their own emit paths and hidden segments are never published, so
|
|
648
|
-
* both are skipped.
|
|
739
|
+
* both are skipped. The publish rules mirror the full sweep exactly, shelf
|
|
740
|
+
* included: a hidden (`.`, `-`, `_`) segment or a symlink is never copied —
|
|
741
|
+
* a symlink's target lives outside the project and its bytes would publish
|
|
742
|
+
* verbatim.
|
|
649
743
|
* @param {string} file Project-relative POSIX path of the changed file.
|
|
650
744
|
* @param {string} cwd
|
|
651
745
|
* @param {Paths} paths
|
|
@@ -654,10 +748,12 @@ function collectionMemberOf(file) {
|
|
|
654
748
|
function copyChangedAsset(file, cwd, paths) {
|
|
655
749
|
const ext = path.posix.extname(file).toLowerCase();
|
|
656
750
|
if ([".md", ".wd", ".skin", ".js"].includes(ext)) return;
|
|
751
|
+
if (fs.lstatSync(path.join(cwd, file)).isSymbolicLink()) return;
|
|
657
752
|
/** @type {string} */
|
|
658
753
|
let out;
|
|
659
754
|
if (file.startsWith(`${shelfDir}/`)) {
|
|
660
755
|
const rel = file.slice(shelfDir.length + 1);
|
|
756
|
+
if (rel.split("/").some((segment) => /^[._-]/.test(segment))) return;
|
|
661
757
|
out = path.join(paths.distRoot, ext === ".json" ? "__wd/data" : "__wd/media", rel);
|
|
662
758
|
} else {
|
|
663
759
|
const rel = file.slice(routesDir.length + 1);
|
|
@@ -694,9 +790,11 @@ function copyChangedAsset(file, cwd, paths) {
|
|
|
694
790
|
* @param {{ href: string, title: string } | undefined} feedLink
|
|
695
791
|
* @param {SiteIdentity} identity
|
|
696
792
|
* @param {Map<string, string>} titles Route path → page title, for breadcrumbs.
|
|
793
|
+
* @param {Map<string, number[]>} imageVariants Generated responsive variants
|
|
794
|
+
* (original's absolute source path → widths) for the srcset wiring.
|
|
697
795
|
* @returns {{ pages: EmittedPage[], deps: Set<string>, collections: Set<string> }}
|
|
698
796
|
*/
|
|
699
|
-
function compileRoutePages(route, paths, collections, feedLink, identity, titles) {
|
|
797
|
+
function compileRoutePages(route, paths, collections, feedLink, identity, titles, imageVariants) {
|
|
700
798
|
/** @type {Set<string>} */
|
|
701
799
|
const deps = new Set();
|
|
702
800
|
/** @type {Set<string>} */
|
|
@@ -721,7 +819,8 @@ function compileRoutePages(route, paths, collections, feedLink, identity, titles
|
|
|
721
819
|
feed: feedLink,
|
|
722
820
|
site: siteFor(route.route),
|
|
723
821
|
collections,
|
|
724
|
-
vars: { page: pagerVars(route.route, 1, 1) }
|
|
822
|
+
vars: { page: pagerVars(route.route, 1, 1) },
|
|
823
|
+
imageVariants
|
|
725
824
|
});
|
|
726
825
|
track(first);
|
|
727
826
|
if (!first.pagination || first.pagination.total <= 1) {
|
|
@@ -746,7 +845,8 @@ function compileRoutePages(route, paths, collections, feedLink, identity, titles
|
|
|
746
845
|
feed: feedLink,
|
|
747
846
|
site: siteFor(pageRoute(route.route, n)),
|
|
748
847
|
collections,
|
|
749
|
-
vars: { page: pagerVars(route.route, n, total) }
|
|
848
|
+
vars: { page: pagerVars(route.route, n, total) },
|
|
849
|
+
imageVariants
|
|
750
850
|
});
|
|
751
851
|
track(page);
|
|
752
852
|
pages.push({
|
|
@@ -1143,7 +1243,11 @@ export default {
|
|
|
1143
1243
|
if (response instanceof Response) return response;
|
|
1144
1244
|
return Response.json({ ok: false, error: "Handler did not return a Response" }, { status: 500 });
|
|
1145
1245
|
} catch (err) {
|
|
1146
|
-
|
|
1246
|
+
// Log the real error server-side; the client gets a generic 500 — a
|
|
1247
|
+
// handler's message can carry internals (paths, query text) that a
|
|
1248
|
+
// public response must not leak. Dev keeps the message (api-runner).
|
|
1249
|
+
console.error(err);
|
|
1250
|
+
return Response.json({ ok: false, error: "Internal error" }, { status: 500 });
|
|
1147
1251
|
}
|
|
1148
1252
|
}
|
|
1149
1253
|
};
|
|
@@ -1188,6 +1292,12 @@ export function stripRuntimeComments(source) {
|
|
|
1188
1292
|
*
|
|
1189
1293
|
* Clean URLs mean a route like `/docs/` is served at both `/docs` and `/docs/`,
|
|
1190
1294
|
* so each static route emits a path glob (`/docs`, `/docs/*`) that covers both.
|
|
1295
|
+
*
|
|
1296
|
+
* A page whose `:embed`s frame hosts beyond the base policy (a booking widget,
|
|
1297
|
+
* a map) carries those hosts on its manifest entry (`assets.embedHosts`), and
|
|
1298
|
+
* its block — static or reactive — states the unioned per-route policy
|
|
1299
|
+
* ({@link routeCsp}). A reactive route with embed hosts can no longer inherit
|
|
1300
|
+
* the catch-all, so it always gets its own block.
|
|
1191
1301
|
* @param {RouteManifestEntry[]} manifest
|
|
1192
1302
|
* @returns {string}
|
|
1193
1303
|
*/
|
|
@@ -1198,9 +1308,10 @@ export function renderCloudflareHeaders(manifest) {
|
|
|
1198
1308
|
const blocks = [["/*", [...baseLines, ` Content-Security-Policy: ${REACTIVE_CSP}`]]];
|
|
1199
1309
|
|
|
1200
1310
|
for (const entry of manifest) {
|
|
1201
|
-
if (entry.assets.runtime) continue; // reactive routes keep the catch-all (eval-free) CSP
|
|
1311
|
+
if (entry.assets.runtime) continue; // plain reactive routes keep the catch-all (eval-free) CSP
|
|
1312
|
+
const csp = routeCsp(false, entry.assets.embedHosts ?? []);
|
|
1202
1313
|
for (const pattern of cloudflarePathPatterns(entry.route)) {
|
|
1203
|
-
blocks.push([pattern, [` Content-Security-Policy: ${
|
|
1314
|
+
blocks.push([pattern, [` Content-Security-Policy: ${csp}`]]);
|
|
1204
1315
|
}
|
|
1205
1316
|
}
|
|
1206
1317
|
|
|
@@ -1209,18 +1320,21 @@ export function renderCloudflareHeaders(manifest) {
|
|
|
1209
1320
|
// was silently resolving to the static policy from its parent. It is a no-op
|
|
1210
1321
|
// only while the two policies happen to be identical. A shadowed reactive
|
|
1211
1322
|
// route therefore re-states its own policy AFTER the static globs, so the
|
|
1212
|
-
// route always lands on the policy its own manifest entry declares.
|
|
1323
|
+
// route always lands on the policy its own manifest entry declares. A
|
|
1324
|
+
// reactive route whose `:embed` hosts widen its policy needs its own block
|
|
1325
|
+
// for the same reason — the catch-all no longer states it.
|
|
1213
1326
|
const shadowingRoutes = manifest
|
|
1214
1327
|
.filter((entry) => !entry.assets.runtime && entry.route !== "/")
|
|
1215
1328
|
.map((entry) => entry.route);
|
|
1216
1329
|
for (const entry of manifest) {
|
|
1217
1330
|
if (!entry.assets.runtime) continue;
|
|
1331
|
+
const csp = routeCsp(true, entry.assets.embedHosts ?? []);
|
|
1218
1332
|
const shadowed = shadowingRoutes.some(
|
|
1219
1333
|
(parent) => entry.route !== parent && entry.route.startsWith(parent)
|
|
1220
1334
|
);
|
|
1221
|
-
if (!shadowed) continue;
|
|
1335
|
+
if (!shadowed && csp === REACTIVE_CSP) continue;
|
|
1222
1336
|
for (const pattern of cloudflarePathPatterns(entry.route)) {
|
|
1223
|
-
blocks.push([pattern, [` Content-Security-Policy: ${
|
|
1337
|
+
blocks.push([pattern, [` Content-Security-Policy: ${csp}`]]);
|
|
1224
1338
|
}
|
|
1225
1339
|
}
|
|
1226
1340
|
|
|
@@ -1442,6 +1556,11 @@ function hasColocatedPage(file) {
|
|
|
1442
1556
|
* STANDALONE `.skin`/`.js` (no sibling page) is a plain shelf asset the docs
|
|
1443
1557
|
* promise at `/__wd/media/<path>` (`<script src="/__wd/media/lib/helper.js">`),
|
|
1444
1558
|
* so it is still published.
|
|
1559
|
+
*
|
|
1560
|
+
* The publish boundary matches `emitPageAssets`: a hidden (`.`, `-`, `_`) path
|
|
1561
|
+
* segment is private framework input, and a symlink is never followed — its
|
|
1562
|
+
* target lives anywhere on the disk and copying it would publish those bytes
|
|
1563
|
+
* verbatim under `/__wd/media/`.
|
|
1445
1564
|
* @param {Paths} paths
|
|
1446
1565
|
* @returns {void}
|
|
1447
1566
|
*/
|
|
@@ -1452,6 +1571,7 @@ function emitShelfAssets(paths) {
|
|
|
1452
1571
|
if (ext === ".md" || ext === ".wd") continue;
|
|
1453
1572
|
if ((ext === ".skin" || ext === ".js") && hasColocatedPage(file)) continue;
|
|
1454
1573
|
const rel = path.relative(paths.shelfRoot, file);
|
|
1574
|
+
if (hasHiddenPathSegment(rel) || fs.lstatSync(file).isSymbolicLink()) continue;
|
|
1455
1575
|
const folder = ext === ".json" ? "__wd/data" : "__wd/media";
|
|
1456
1576
|
const out = path.join(paths.distRoot, folder, rel);
|
|
1457
1577
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
package/src/catalog.js
CHANGED
|
@@ -58,7 +58,8 @@ import { ERROR_AREAS, errorCatalog } from "./errors.js";
|
|
|
58
58
|
/**
|
|
59
59
|
* @typedef {object} DirectiveEntry
|
|
60
60
|
* @property {string} name The directive token (`@loop`, `:state`, `:::`, …).
|
|
61
|
-
* @property {"line" | "block"} kind A single line,
|
|
61
|
+
* @property {"line" | "block" | "either"} kind A single line, an opener with a
|
|
62
|
+
* closer, or a line that may optionally open a block (`@include … @endinclude`).
|
|
62
63
|
* @property {string} syntax One-line schematic (`[…]` = optional, `<…>` = slot).
|
|
63
64
|
* @property {string} description One-line, plain-language summary.
|
|
64
65
|
* @property {string} example One concrete, compilable line.
|
|
@@ -97,10 +98,11 @@ import { ERROR_AREAS, errorCatalog } from "./errors.js";
|
|
|
97
98
|
const DIRECTIVES = [
|
|
98
99
|
{
|
|
99
100
|
name: "@include",
|
|
100
|
-
kind: "
|
|
101
|
-
syntax:
|
|
101
|
+
kind: "either",
|
|
102
|
+
syntax:
|
|
103
|
+
'@include /partial.wd [with key="value" …] | @include /partial.wd [with …] …children… @endinclude',
|
|
102
104
|
description:
|
|
103
|
-
|
|
105
|
+
'Inline another .wd/.md file from site/pages or site/_, passing optional scope args. Block form compiles the lines before @endinclude in the caller\'s scope and places them where the file puts a standalone `{ children }` line (empty when called without a block). The file may declare `defaults: [name="value"]` frontmatter for args the caller did not pass.',
|
|
104
106
|
example: INCLUDE_EXAMPLE,
|
|
105
107
|
reactive: "static"
|
|
106
108
|
},
|
|
@@ -622,6 +624,12 @@ const FRONTMATTER_KEYS = [
|
|
|
622
624
|
description: "Allow raw HTML in the markdown body (default false).",
|
|
623
625
|
example: "html: true"
|
|
624
626
|
},
|
|
627
|
+
{
|
|
628
|
+
name: "defaults",
|
|
629
|
+
description:
|
|
630
|
+
'INCLUDE FILES ONLY: fallback values for `with` arguments the caller did not pass (an inline array of name="value" entries; values are strings).',
|
|
631
|
+
example: 'defaults: [title="Untitled"]'
|
|
632
|
+
},
|
|
625
633
|
{
|
|
626
634
|
name: "transitions",
|
|
627
635
|
description: "Opt into cross-document view transitions and link prerendering.",
|
|
@@ -693,6 +701,7 @@ function blockStructureSection() {
|
|
|
693
701
|
"- `@loop … @endloop`. `@empty` inside the loop body renders when the list is empty.",
|
|
694
702
|
"- `:if … :endif`, with optional `:else if <condition>` and `:else` branches before the close.",
|
|
695
703
|
"- `:form … :endform`, `:carousel … :endcarousel`, and `::: … :::` (a bare `:::` closes a container).",
|
|
704
|
+
"- `@include /partial.wd … @endinclude` compiles the lines between in the caller's scope and places them at the partial's standalone `{ children }` line (the slot renders empty when called without a block). `@endinclude` pairs with the nearest preceding unpaired `@include`.",
|
|
696
705
|
""
|
|
697
706
|
];
|
|
698
707
|
}
|