@zvndev/darkmown 2.9.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +20 -9
- package/CHANGELOG.md +13 -0
- package/README.md +104 -5
- package/docs/spec-alignment.md +7 -1
- package/grammar/wd-directives.gbnf +1 -1
- package/package.json +7 -3
- package/site/pages/benchmarks/index.skin +67 -0
- package/site/pages/benchmarks/index.wd +126 -0
- package/site/pages/docs/index.wd +47 -2
- package/site/pages/index.wd +1 -0
- package/src/builder.js +66 -13
- package/src/catalog.js +13 -4
- package/src/cli.js +5 -1
- package/src/compiler/body.js +30 -1
- package/src/compiler/context.js +13 -1
- package/src/compiler/includes.js +44 -1
- package/src/compiler/media.js +25 -0
- package/src/compiler/page.js +6 -0
- package/src/compiler/structure.js +272 -4
- 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/statics.js +22 -14
- package/types/builder.d.ts +25 -1
- package/types/catalog.d.ts +3 -2
- package/types/compiler/context.d.ts +28 -0
- package/types/compiler/includes.d.ts +23 -0
- package/types/compiler/structure.d.ts +53 -1
- package/types/font-subset.d.ts +140 -0
- package/types/headers.d.ts +14 -0
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:
|
|
@@ -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,7 +19,8 @@ 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";
|
|
24
25
|
import { generateImageVariants } from "./image-variants.js";
|
|
25
26
|
import { discoverRoutes, outputPathForRoute } from "./router.js";
|
|
@@ -37,7 +38,10 @@ import { compileSkin, scopeIdFor } from "./skin.js";
|
|
|
37
38
|
* @typedef {object} RouteManifestEntry
|
|
38
39
|
* @property {string} route Public route path.
|
|
39
40
|
* @property {string} file Source file path, relative to cwd, POSIX-separated.
|
|
40
|
-
* @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.
|
|
41
45
|
*/
|
|
42
46
|
|
|
43
47
|
/**
|
|
@@ -60,8 +64,10 @@ import { compileSkin, scopeIdFor } from "./skin.js";
|
|
|
60
64
|
export const DEP_MAP_FILE = ".wd-dev-deps.json";
|
|
61
65
|
|
|
62
66
|
// Bumped whenever the map's shape changes, so a stale map from an older
|
|
63
|
-
// Darkmown falls back to a full rebuild instead of being misread.
|
|
64
|
-
|
|
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;
|
|
65
71
|
|
|
66
72
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
67
73
|
|
|
@@ -109,6 +115,9 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
109
115
|
* @property {{ generated: number, cached: number, unavailable: boolean }} images
|
|
110
116
|
* Responsive-variant pre-pass counts (2.9.0): freshly resized, cache-served,
|
|
111
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.
|
|
112
121
|
* @property {{ changed: string[], rebuilt: string[], total: number }} [incremental]
|
|
113
122
|
*/
|
|
114
123
|
|
|
@@ -200,6 +209,13 @@ function buildFull(cwd, paths, options) {
|
|
|
200
209
|
// sees every emitted route/framework file already on disk.
|
|
201
210
|
emitPageAssets(paths);
|
|
202
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
|
+
|
|
203
219
|
// Surface api/ routing footguns on every build (target-independent): two files
|
|
204
220
|
// that resolve to the same /api path mean one is silently unreachable.
|
|
205
221
|
const apiDir = path.join(cwd, "api");
|
|
@@ -216,7 +232,8 @@ function buildFull(cwd, paths, options) {
|
|
|
216
232
|
routes: manifest,
|
|
217
233
|
distRoot: paths.distRoot,
|
|
218
234
|
feeds,
|
|
219
|
-
images: { generated: images.generated, cached: images.cached, unavailable: images.unavailable }
|
|
235
|
+
images: { generated: images.generated, cached: images.cached, unavailable: images.unavailable },
|
|
236
|
+
fonts: { subsetted: fonts.subsetted, cached: fonts.cached, unavailable: fonts.unavailable }
|
|
220
237
|
};
|
|
221
238
|
}
|
|
222
239
|
|
|
@@ -364,11 +381,17 @@ function buildIncremental(cwd, paths, options, changed) {
|
|
|
364
381
|
const feeds = emitFeeds([...routes, ...extraRoutes], identity, paths, options.quietFeedHints);
|
|
365
382
|
writeDepMap(paths, feedLink, siteInputs(identity, titles), map.routes);
|
|
366
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
|
+
|
|
367
389
|
return {
|
|
368
390
|
routes: manifest,
|
|
369
391
|
distRoot: paths.distRoot,
|
|
370
392
|
feeds,
|
|
371
393
|
images: { generated: images.generated, cached: images.cached, unavailable: images.unavailable },
|
|
394
|
+
fonts: { subsetted: fonts.subsetted, cached: fonts.cached, unavailable: fonts.unavailable },
|
|
372
395
|
incremental: { changed, rebuilt, total: routes.length }
|
|
373
396
|
};
|
|
374
397
|
}
|
|
@@ -430,6 +453,10 @@ function buildRoute(
|
|
|
430
453
|
emitBehaviors(page.assets, paths);
|
|
431
454
|
if (page.assets.hasCode) emitHighlight(paths);
|
|
432
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();
|
|
433
460
|
entries.push({
|
|
434
461
|
route: page.route,
|
|
435
462
|
file: relPosix(cwd, route.file),
|
|
@@ -440,7 +467,8 @@ function buildRoute(
|
|
|
440
467
|
...behaviorSrcs,
|
|
441
468
|
...page.assets.scripts
|
|
442
469
|
],
|
|
443
|
-
runtime: page.assets.runtime
|
|
470
|
+
runtime: page.assets.runtime,
|
|
471
|
+
...(embedHosts.length > 0 ? { embedHosts } : {})
|
|
444
472
|
}
|
|
445
473
|
});
|
|
446
474
|
}
|
|
@@ -708,7 +736,10 @@ function collectionMemberOf(file) {
|
|
|
708
736
|
* this point — in practice `@loop` JSON data files, published under
|
|
709
737
|
* `/__wd/data/` (shelf) or beside their page (site/pages). Page/skin/script
|
|
710
738
|
* sources have their own emit paths and hidden segments are never published, so
|
|
711
|
-
* 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.
|
|
712
743
|
* @param {string} file Project-relative POSIX path of the changed file.
|
|
713
744
|
* @param {string} cwd
|
|
714
745
|
* @param {Paths} paths
|
|
@@ -717,10 +748,12 @@ function collectionMemberOf(file) {
|
|
|
717
748
|
function copyChangedAsset(file, cwd, paths) {
|
|
718
749
|
const ext = path.posix.extname(file).toLowerCase();
|
|
719
750
|
if ([".md", ".wd", ".skin", ".js"].includes(ext)) return;
|
|
751
|
+
if (fs.lstatSync(path.join(cwd, file)).isSymbolicLink()) return;
|
|
720
752
|
/** @type {string} */
|
|
721
753
|
let out;
|
|
722
754
|
if (file.startsWith(`${shelfDir}/`)) {
|
|
723
755
|
const rel = file.slice(shelfDir.length + 1);
|
|
756
|
+
if (rel.split("/").some((segment) => /^[._-]/.test(segment))) return;
|
|
724
757
|
out = path.join(paths.distRoot, ext === ".json" ? "__wd/data" : "__wd/media", rel);
|
|
725
758
|
} else {
|
|
726
759
|
const rel = file.slice(routesDir.length + 1);
|
|
@@ -1210,7 +1243,11 @@ export default {
|
|
|
1210
1243
|
if (response instanceof Response) return response;
|
|
1211
1244
|
return Response.json({ ok: false, error: "Handler did not return a Response" }, { status: 500 });
|
|
1212
1245
|
} catch (err) {
|
|
1213
|
-
|
|
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 });
|
|
1214
1251
|
}
|
|
1215
1252
|
}
|
|
1216
1253
|
};
|
|
@@ -1255,6 +1292,12 @@ export function stripRuntimeComments(source) {
|
|
|
1255
1292
|
*
|
|
1256
1293
|
* Clean URLs mean a route like `/docs/` is served at both `/docs` and `/docs/`,
|
|
1257
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.
|
|
1258
1301
|
* @param {RouteManifestEntry[]} manifest
|
|
1259
1302
|
* @returns {string}
|
|
1260
1303
|
*/
|
|
@@ -1265,9 +1308,10 @@ export function renderCloudflareHeaders(manifest) {
|
|
|
1265
1308
|
const blocks = [["/*", [...baseLines, ` Content-Security-Policy: ${REACTIVE_CSP}`]]];
|
|
1266
1309
|
|
|
1267
1310
|
for (const entry of manifest) {
|
|
1268
|
-
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 ?? []);
|
|
1269
1313
|
for (const pattern of cloudflarePathPatterns(entry.route)) {
|
|
1270
|
-
blocks.push([pattern, [` Content-Security-Policy: ${
|
|
1314
|
+
blocks.push([pattern, [` Content-Security-Policy: ${csp}`]]);
|
|
1271
1315
|
}
|
|
1272
1316
|
}
|
|
1273
1317
|
|
|
@@ -1276,18 +1320,21 @@ export function renderCloudflareHeaders(manifest) {
|
|
|
1276
1320
|
// was silently resolving to the static policy from its parent. It is a no-op
|
|
1277
1321
|
// only while the two policies happen to be identical. A shadowed reactive
|
|
1278
1322
|
// route therefore re-states its own policy AFTER the static globs, so the
|
|
1279
|
-
// 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.
|
|
1280
1326
|
const shadowingRoutes = manifest
|
|
1281
1327
|
.filter((entry) => !entry.assets.runtime && entry.route !== "/")
|
|
1282
1328
|
.map((entry) => entry.route);
|
|
1283
1329
|
for (const entry of manifest) {
|
|
1284
1330
|
if (!entry.assets.runtime) continue;
|
|
1331
|
+
const csp = routeCsp(true, entry.assets.embedHosts ?? []);
|
|
1285
1332
|
const shadowed = shadowingRoutes.some(
|
|
1286
1333
|
(parent) => entry.route !== parent && entry.route.startsWith(parent)
|
|
1287
1334
|
);
|
|
1288
|
-
if (!shadowed) continue;
|
|
1335
|
+
if (!shadowed && csp === REACTIVE_CSP) continue;
|
|
1289
1336
|
for (const pattern of cloudflarePathPatterns(entry.route)) {
|
|
1290
|
-
blocks.push([pattern, [` Content-Security-Policy: ${
|
|
1337
|
+
blocks.push([pattern, [` Content-Security-Policy: ${csp}`]]);
|
|
1291
1338
|
}
|
|
1292
1339
|
}
|
|
1293
1340
|
|
|
@@ -1509,6 +1556,11 @@ function hasColocatedPage(file) {
|
|
|
1509
1556
|
* STANDALONE `.skin`/`.js` (no sibling page) is a plain shelf asset the docs
|
|
1510
1557
|
* promise at `/__wd/media/<path>` (`<script src="/__wd/media/lib/helper.js">`),
|
|
1511
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/`.
|
|
1512
1564
|
* @param {Paths} paths
|
|
1513
1565
|
* @returns {void}
|
|
1514
1566
|
*/
|
|
@@ -1519,6 +1571,7 @@ function emitShelfAssets(paths) {
|
|
|
1519
1571
|
if (ext === ".md" || ext === ".wd") continue;
|
|
1520
1572
|
if ((ext === ".skin" || ext === ".js") && hasColocatedPage(file)) continue;
|
|
1521
1573
|
const rel = path.relative(paths.shelfRoot, file);
|
|
1574
|
+
if (hasHiddenPathSegment(rel) || fs.lstatSync(file).isSymbolicLink()) continue;
|
|
1522
1575
|
const folder = ext === ".json" ? "__wd/data" : "__wd/media";
|
|
1523
1576
|
const out = path.join(paths.distRoot, folder, rel);
|
|
1524
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
|
}
|
package/src/cli.js
CHANGED
|
@@ -604,7 +604,7 @@ Authoring:
|
|
|
604
604
|
* An incremental (dev `--changed`) build reports what it actually rebuilt —
|
|
605
605
|
* `Rebuilt 2 of 14 routes (/blog/, /blog/hello/) into dist` — naming the routes
|
|
606
606
|
* when there are few enough to read.
|
|
607
|
-
* @param {{ routes: unknown[], feeds: { sitemap: number | null, rss: number | null }, incremental?: { rebuilt: string[], total: number }, images?: { generated: number, cached: number, unavailable: boolean } }} result
|
|
607
|
+
* @param {{ routes: unknown[], feeds: { sitemap: number | null, rss: number | null }, incremental?: { rebuilt: string[], total: number }, images?: { generated: number, cached: number, unavailable: boolean }, fonts?: { subsetted: number, cached: number, unavailable: boolean } }} result
|
|
608
608
|
* @param {string} relativeRoot `dist` relative to cwd, for the message tail.
|
|
609
609
|
* @returns {string}
|
|
610
610
|
*/
|
|
@@ -623,6 +623,10 @@ export function buildSummary(result, relativeRoot) {
|
|
|
623
623
|
`image variants (${result.images.generated} generated, ${result.images.cached} cached)`
|
|
624
624
|
);
|
|
625
625
|
}
|
|
626
|
+
// Font post-pass (2.10.0): same rule — report only when subsets materialized.
|
|
627
|
+
if (result.fonts && result.fonts.subsetted + result.fonts.cached > 0) {
|
|
628
|
+
parts.push(`fonts (${result.fonts.subsetted} subsetted, ${result.fonts.cached} cached)`);
|
|
629
|
+
}
|
|
626
630
|
return `${parts.join(", ")} into ${relativeRoot}`;
|
|
627
631
|
}
|
|
628
632
|
|
package/src/compiler/body.js
CHANGED
|
@@ -33,8 +33,10 @@ import {
|
|
|
33
33
|
handleTheme,
|
|
34
34
|
renderDemoDirective
|
|
35
35
|
} from "./directives.js";
|
|
36
|
+
import { CHILDREN_SLOT_LINE, childrenSlot } from "./includes.js";
|
|
36
37
|
import { handleLoop, spliceTables } from "./loops.js";
|
|
37
38
|
import { renderProse } from "./markdown.js";
|
|
39
|
+
import { scanIncludeBlock, strayEndinclude } from "./structure.js";
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
@@ -148,6 +150,21 @@ export function compileBody(lines, ctx) {
|
|
|
148
150
|
}
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
// A standalone `{ children }` line is an include's slot marker: when the
|
|
154
|
+
// file being compiled was called as a block-form @include, the compiled
|
|
155
|
+
// children HTML (built in the caller's context by handleInclude) splices
|
|
156
|
+
// in here. With no slot in scope — a top-level page — the line falls
|
|
157
|
+
// through to prose and stays the literal text `{ children }`; an include
|
|
158
|
+
// called without a block carries an empty slot and splices nothing.
|
|
159
|
+
if (!inRawBlock && CHILDREN_SLOT_LINE.test(line)) {
|
|
160
|
+
const slot = childrenSlot(ctx);
|
|
161
|
+
if (slot !== null) {
|
|
162
|
+
flush();
|
|
163
|
+
if (slot) push(slot);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
151
168
|
// A directive name ALONE on a line is a mistake with one obvious fix: it is
|
|
152
169
|
// missing its arguments. Every dispatcher below requires a space after the
|
|
153
170
|
// keyword, so such a line used to fall through to prose — and because
|
|
@@ -164,7 +181,15 @@ export function compileBody(lines, ctx) {
|
|
|
164
181
|
|
|
165
182
|
if (/^@include\s/.test(line)) {
|
|
166
183
|
flush();
|
|
167
|
-
|
|
184
|
+
const block = scanIncludeBlock(lines, i);
|
|
185
|
+
if (block) {
|
|
186
|
+
const includeAt = ctx.comp.symbols.length;
|
|
187
|
+
push(handleInclude(line, ctx, i, block.body));
|
|
188
|
+
stampBlockEnd(ctx, includeAt, block.end);
|
|
189
|
+
i = block.end;
|
|
190
|
+
} else {
|
|
191
|
+
push(handleInclude(line, ctx, i));
|
|
192
|
+
}
|
|
168
193
|
continue;
|
|
169
194
|
}
|
|
170
195
|
if (/^@loop\s/.test(line)) {
|
|
@@ -340,6 +365,10 @@ export function compileBody(lines, ctx) {
|
|
|
340
365
|
{ code: "WD009", file: ctx.file, hint: "@loop items into item ... @endloop" }
|
|
341
366
|
);
|
|
342
367
|
}
|
|
368
|
+
// `@endinclude` that reached the dispatcher: every opener that could pair
|
|
369
|
+
// with it already did (scanIncludeBlock consumes paired closers), so this
|
|
370
|
+
// one has no opener of its own — one obvious fix, its own WD6xx code.
|
|
371
|
+
if (/^@endinclude(?:\s|$)/.test(line)) strayEndinclude(line, ctx, i);
|
|
343
372
|
// `:endcarousel` joins the family: a closer with no opener is a mistake with
|
|
344
373
|
// one obvious fix, and the vague "matches none" warning it used to get named
|
|
345
374
|
// the wrong problem ("check the spelling") for a token spelled correctly.
|
package/src/compiler/context.js
CHANGED
|
@@ -39,6 +39,12 @@
|
|
|
39
39
|
* that opted into scoping (`scoped` first line). The builder emits these with
|
|
40
40
|
* the scope attribute and runs the unused-selector warning against the stamped
|
|
41
41
|
* subtree; the HTML stamp uses them to scope the matching page/include subtree.
|
|
42
|
+
* @property {Set<string>} embedHosts Frame sources (origins like
|
|
43
|
+
* `https://app.example.com`, or `'self'` for a same-origin embed) of every
|
|
44
|
+
* `:embed` on the page, collected AFTER the YouTube/Vimeo rewrite. The builder
|
|
45
|
+
* records them on the route manifest entry so the per-route CSP's `frame-src`
|
|
46
|
+
* authorizes exactly the hosts this page frames (see `routeCsp` in
|
|
47
|
+
* src/headers.js).
|
|
42
48
|
*/
|
|
43
49
|
|
|
44
50
|
/**
|
|
@@ -147,6 +153,11 @@
|
|
|
147
153
|
* opener's `file:line` + raw text, set when descending into a loop body so the
|
|
148
154
|
* reactive-nesting-depth error can point at the offending opener.
|
|
149
155
|
* @property {boolean} [loopMeta] Inside a loop body, so `$index`/`$first`/… are valid.
|
|
156
|
+
* @property {number} [includeBlockDepth] Enclosing block-form `@include`
|
|
157
|
+
* CHILDREN levels (0 at page level, absent on a fresh file compile). Set
|
|
158
|
+
* while compiling a block's children so a block include nested past one
|
|
159
|
+
* level inside children is a compile error (WD616, together with the
|
|
160
|
+
* include-stack depth check for include bodies).
|
|
150
161
|
* @property {import("markdown-it").default} [md] Markdown-it instance selected for this file.
|
|
151
162
|
* @property {(lines: string[], ctx: Ctx) => string} compileBody The body
|
|
152
163
|
* dispatcher, threaded through the context so block handlers (`:if`, `:::`,
|
|
@@ -303,7 +314,8 @@ export function createCompilation(reader) {
|
|
|
303
314
|
runtime: false,
|
|
304
315
|
behaviors: new Set(),
|
|
305
316
|
hasCode: false,
|
|
306
|
-
scopedSkins: new Set()
|
|
317
|
+
scopedSkins: new Set(),
|
|
318
|
+
embedHosts: new Set()
|
|
307
319
|
},
|
|
308
320
|
state: new Map(),
|
|
309
321
|
stores: new Set(),
|
package/src/compiler/includes.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { scopeIdFor } from "../skin.js";
|
|
10
10
|
import { pageIncludeExtensions, wdError } from "./context.js";
|
|
11
|
-
import { stripQuotes } from "./interpolation.js";
|
|
11
|
+
import { lookupVar, stripQuotes } from "./interpolation.js";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* @typedef {import("./context.js").Paths} Paths
|
|
@@ -244,6 +244,49 @@ export function isAllowedInclude(file, context, reader) {
|
|
|
244
244
|
return contains(canonical(path.resolve(file)), roots.map(canonical));
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// The `{ children }` slot (block-form @include).
|
|
249
|
+
//
|
|
250
|
+
// `@include /card.wd … @endinclude` compiles the lines between opener and
|
|
251
|
+
// closer in the CALLER's context and hands the resulting HTML to the include
|
|
252
|
+
// file, which places it with a standalone `{ children }` line. The hand-off
|
|
253
|
+
// travels in the static scope chain under a key no author syntax can produce
|
|
254
|
+
// (interpolation, `with` args, and `:state` names are all `[A-Za-z0-9_$-]`
|
|
255
|
+
// shaped), so a `{ children }` interpolation in prose never resolves it — only
|
|
256
|
+
// the body parser's slot-marker intercept reads it. Pure macro expansion: the
|
|
257
|
+
// children compile once, at build time, and the include stays zero-JS unless
|
|
258
|
+
// the children themselves bind state.
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Scope key carrying the compiled children HTML for the include currently
|
|
263
|
+
* being compiled. Control-character-prefixed so no author-visible name can
|
|
264
|
+
* collide with it or read it through `{ }` interpolation.
|
|
265
|
+
*/
|
|
266
|
+
export const INCLUDE_CHILDREN = "\0children";
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A line that is exactly the slot marker (`{ children }`, whitespace-tolerant),
|
|
270
|
+
* matching the spelling freedom of `{ name }` interpolation. Shared by the
|
|
271
|
+
* body parser (which splices the slot) and the include handler (which scans an
|
|
272
|
+
* include's body for whether the slot is ever used).
|
|
273
|
+
* @type {RegExp}
|
|
274
|
+
*/
|
|
275
|
+
export const CHILDREN_SLOT_LINE = /^\s*\{\s*children\s*\}\s*$/;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The compiled children HTML for the include currently compiling, or null when
|
|
279
|
+
* the file is not being compiled as an include at all (a top-level page) — in
|
|
280
|
+
* which case a `{ children }` line stays literal text. An include called
|
|
281
|
+
* WITHOUT a block carries "" (the slot renders empty).
|
|
282
|
+
* @param {import("./context.js").Ctx} ctx
|
|
283
|
+
* @returns {string | null}
|
|
284
|
+
*/
|
|
285
|
+
export function childrenSlot(ctx) {
|
|
286
|
+
const found = lookupVar(ctx.scope, INCLUDE_CHILDREN);
|
|
287
|
+
return found.found ? /** @type {string} */ (found.value) : null;
|
|
288
|
+
}
|
|
289
|
+
|
|
247
290
|
// ---------------------------------------------------------------------------
|
|
248
291
|
// Plain .md hints
|
|
249
292
|
// ---------------------------------------------------------------------------
|
package/src/compiler/media.js
CHANGED
|
@@ -175,6 +175,7 @@ export function handleEmbed(line, ctx, index) {
|
|
|
175
175
|
const sizing = embedSizing(attrs, ctx, index);
|
|
176
176
|
const allow = embedAllow(attrs, ctx, index);
|
|
177
177
|
const { url, label } = resolveEmbed(raw, ctx);
|
|
178
|
+
recordEmbedHost(ctx, url);
|
|
178
179
|
const title = attrs.get("title") || "";
|
|
179
180
|
const iframe =
|
|
180
181
|
`<iframe src="${escapeHtml(url)}" title="${escapeHtml(title || label)}" loading="lazy" ` +
|
|
@@ -237,6 +238,30 @@ function embedAllow(attrs, ctx, index) {
|
|
|
237
238
|
return tokens.join("; ");
|
|
238
239
|
}
|
|
239
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Record the frame source the emitted iframe actually loads, so the builder can
|
|
243
|
+
* union it into this route's CSP `frame-src`. An absolute http(s) URL contributes
|
|
244
|
+
* its origin; a relative URL frames the page's own origin, so it contributes
|
|
245
|
+
* `'self'`. A `{ state }`-driven URL is unknowable at compile time and records
|
|
246
|
+
* nothing (widening that policy is the app's deploy-config decision, exactly
|
|
247
|
+
* like a remote `:fetch` target widening `connect-src`).
|
|
248
|
+
* @param {Ctx} ctx
|
|
249
|
+
* @param {string} url The FINAL iframe src (post YouTube/Vimeo rewrite).
|
|
250
|
+
* @returns {void}
|
|
251
|
+
*/
|
|
252
|
+
function recordEmbedHost(ctx, url) {
|
|
253
|
+
if (url.startsWith("{")) return;
|
|
254
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
255
|
+
ctx.comp.assets.embedHosts.add("'self'");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
ctx.comp.assets.embedHosts.add(new URL(url).origin);
|
|
260
|
+
} catch {
|
|
261
|
+
/* a malformed-but-scheme-valid URL paints as-is; nothing to authorize */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
240
265
|
/**
|
|
241
266
|
* Map a watch/share URL to its embeddable form. YouTube → no-cookie embed, Vimeo →
|
|
242
267
|
* player; anything else is validated as a generic http(s) iframe source.
|
package/src/compiler/page.js
CHANGED
|
@@ -14,6 +14,7 @@ import { parseFrontmatter, resolveSharedSkins, warnLikelyFrontmatter } from "./f
|
|
|
14
14
|
import { imageSize } from "./image-size.js";
|
|
15
15
|
import {
|
|
16
16
|
collectColocatedAssets,
|
|
17
|
+
isAllowedInclude,
|
|
17
18
|
scanMarkdownHints,
|
|
18
19
|
scopedSkinFor,
|
|
19
20
|
scopeInfoForSkin,
|
|
@@ -408,6 +409,10 @@ function srcOf(tag) {
|
|
|
408
409
|
* Read an image's intrinsic dimensions from disk, or null when the src is
|
|
409
410
|
* remote/relative/unreadable. Resolution mirrors the asset emit: `/__wd/media/x`
|
|
410
411
|
* comes from the shelf (`site/_`), other absolute paths from `site/pages`.
|
|
412
|
+
* Containment mirrors the `@include` sandbox ({@link isAllowedInclude}): a src
|
|
413
|
+
* that resolves OUTSIDE those roots — ``, or a symlink
|
|
414
|
+
* pointing out — is treated exactly like a missing image (no dimensions), never
|
|
415
|
+
* read.
|
|
411
416
|
* @param {string} src
|
|
412
417
|
* @param {Paths} paths
|
|
413
418
|
* @param {import("./reader.js").Reader} reader
|
|
@@ -423,6 +428,7 @@ function measureImage(src, paths, reader) {
|
|
|
423
428
|
} else {
|
|
424
429
|
return null; // page-relative: the source directory is lost after assembly
|
|
425
430
|
}
|
|
431
|
+
if (!isAllowedInclude(filePath, paths, reader)) return null;
|
|
426
432
|
try {
|
|
427
433
|
// `imageSize` itself never throws (an unsupported or malformed image is just
|
|
428
434
|
// null); the try/catch is for the read, which does throw on a missing file.
|