@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
package/src/api-runner.js CHANGED
@@ -78,13 +78,17 @@ function dynamicCount(segments) {
78
78
  /**
79
79
  * Match a request pathname against discovered routes. The pathname is expected
80
80
  * to start with `/api`; anything else returns `null` (caller serves static).
81
+ * A dynamic segment whose percent-encoding is malformed (`%E0%A4%A`) makes
82
+ * `decodeURIComponent` throw — that is reported as `{ malformed: true }` so the
83
+ * caller answers a clean 400 instead of crashing the request handler.
81
84
  * @param {ApiRoute[]} routes
82
85
  * @param {string} pathname Request pathname (no query string).
83
- * @returns {{ file: string, params: Record<string, string> } | null}
86
+ * @returns {{ file: string, params: Record<string, string> } | { malformed: true } | null}
84
87
  */
85
88
  export function matchApiRoute(routes, pathname) {
86
89
  if (pathname !== "/api" && !pathname.startsWith("/api/")) return null;
87
90
  const parts = pathname.split("/").filter(Boolean).slice(1); // drop leading "api"
91
+ let sawMalformed = false;
88
92
  for (const route of routes) {
89
93
  if (route.segments.length !== parts.length) continue;
90
94
  /** @type {Record<string, string>} */
@@ -93,7 +97,15 @@ export function matchApiRoute(routes, pathname) {
93
97
  for (let i = 0; i < parts.length; i++) {
94
98
  const seg = route.segments[i];
95
99
  if (seg.startsWith("[") && seg.endsWith("]")) {
96
- params[seg.slice(1, -1)] = decodeURIComponent(parts[i]);
100
+ try {
101
+ params[seg.slice(1, -1)] = decodeURIComponent(parts[i]);
102
+ } catch {
103
+ // Malformed here does not preclude a static match on a later route,
104
+ // so keep scanning and report only if nothing else claimed the path.
105
+ sawMalformed = true;
106
+ ok = false;
107
+ break;
108
+ }
97
109
  } else if (seg !== parts[i]) {
98
110
  ok = false;
99
111
  break;
@@ -101,13 +113,15 @@ export function matchApiRoute(routes, pathname) {
101
113
  }
102
114
  if (ok) return { file: route.file, params };
103
115
  }
104
- return null;
116
+ return sawMalformed ? { malformed: true } : null;
105
117
  }
106
118
 
107
119
  /**
108
120
  * Build a Web-standard `Request` from a Node `IncomingMessage`. The body (for
109
- * methods that carry one) is buffered fully — dev endpoints are small and this
110
- * avoids the `duplex: "half"` streaming dance.
121
+ * methods that carry one) is buffered up to {@link MAX_BODY_BYTES} — dev and
122
+ * preview endpoints are small and this avoids the `duplex: "half"` streaming
123
+ * dance. A larger body rejects with {@link BodyTooLargeError}, which
124
+ * `handleApiRequest` answers as a 413.
111
125
  * @param {import("node:http").IncomingMessage} req
112
126
  * @returns {Promise<Request>}
113
127
  */
@@ -125,6 +139,15 @@ export async function nodeRequestToWeb(req) {
125
139
  return new Request(url, init);
126
140
  }
127
141
 
142
+ /** Bodies larger than this are rejected with a 413 rather than buffered without bound. */
143
+ const MAX_BODY_BYTES = 10 * 1024 * 1024;
144
+
145
+ /**
146
+ * Rejection marker for an over-cap request body, so `handleApiRequest` answers
147
+ * 413 (a client error) instead of a generic 500.
148
+ */
149
+ class BodyTooLargeError extends Error {}
150
+
128
151
  /**
129
152
  * @param {import("node:http").IncomingMessage} req
130
153
  * @returns {Promise<Buffer>}
@@ -133,7 +156,22 @@ function readBody(req) {
133
156
  return new Promise((resolve, reject) => {
134
157
  /** @type {Buffer[]} */
135
158
  const chunks = [];
136
- req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
159
+ let received = 0;
160
+ let over = false;
161
+ req.on("data", (chunk) => {
162
+ received += chunk.length;
163
+ if (received > MAX_BODY_BYTES) {
164
+ // Stop accumulating, but keep draining the socket: destroying the
165
+ // request here would kill the connection before the 413 answer can
166
+ // reach the client (verified: `req.destroy()` mid-upload → EPIPE).
167
+ if (!over) {
168
+ over = true;
169
+ reject(new BodyTooLargeError(`Request body too large (over ${MAX_BODY_BYTES} bytes)`));
170
+ }
171
+ return;
172
+ }
173
+ chunks.push(Buffer.from(chunk));
174
+ });
137
175
  req.on("end", () => resolve(Buffer.concat(chunks)));
138
176
  req.on("error", reject);
139
177
  });
@@ -189,18 +227,41 @@ export async function writeWebResponse(response, res, acceptEncoding) {
189
227
  * the handler succeeded or threw), `false` when nothing matched so the caller
190
228
  * should fall through to static serving.
191
229
  *
230
+ * Error disclosure: a handler throw is answered as JSON either way, but the
231
+ * `error` field carries the real message only when `discloseErrors` is set
232
+ * (the dev server's default — a documented dev convenience). The preview
233
+ * server passes `false` and the client gets the generic "Internal error",
234
+ * matching the shape the generated Cloudflare worker ships.
235
+ *
192
236
  * @param {object} io
193
237
  * @param {string} io.apiDir Absolute path to the project's `api/` directory.
194
238
  * @param {import("node:http").IncomingMessage} io.req
195
239
  * @param {import("node:http").ServerResponse} io.res
196
240
  * @param {string | string[] | undefined} [io.acceptEncoding] The request's
197
241
  * Accept-Encoding header, when the caller negotiates compression (preview).
242
+ * @param {boolean} [io.discloseErrors] Echo the thrown message to the client
243
+ * (default true, the dev behavior). Pass false on any production-shaped path.
198
244
  * @returns {Promise<boolean>}
199
245
  */
200
- export async function handleApiRequest({ apiDir, req, res, acceptEncoding }) {
246
+ export async function handleApiRequest({
247
+ apiDir,
248
+ req,
249
+ res,
250
+ acceptEncoding,
251
+ discloseErrors = true
252
+ }) {
201
253
  const pathname = new URL(req.url || "/", "http://localhost").pathname;
202
254
  const match = matchApiRoute(discoverApiRoutes(apiDir), pathname);
203
255
  if (!match) return false;
256
+ if ("malformed" in match) {
257
+ // A malformed percent-encoded dynamic segment: the URL itself is at fault,
258
+ // so this is a 400, not a 500 — and it must never reach the caller's catch.
259
+ await writeWebResponse(
260
+ Response.json({ ok: false, error: "Malformed URL encoding" }, { status: 400 }),
261
+ res
262
+ );
263
+ return true;
264
+ }
204
265
 
205
266
  try {
206
267
  const handler = await importHandler(match.file);
@@ -215,7 +276,18 @@ export async function handleApiRequest({ apiDir, req, res, acceptEncoding }) {
215
276
  }
216
277
  await writeWebResponse(response, res, acceptEncoding);
217
278
  } catch (err) {
218
- const message = err instanceof Error ? err.message : String(err);
279
+ if (err instanceof BodyTooLargeError) {
280
+ await writeWebResponse(
281
+ Response.json({ ok: false, error: "Request body too large" }, { status: 413 }),
282
+ res
283
+ );
284
+ return true;
285
+ }
286
+ const message = discloseErrors
287
+ ? err instanceof Error
288
+ ? err.message
289
+ : String(err)
290
+ : "Internal error";
219
291
  await writeWebResponse(Response.json({ ok: false, error: message }, { status: 500 }), res);
220
292
  }
221
293
  return true;
package/src/builder.js CHANGED
@@ -19,7 +19,8 @@ import {
19
19
  sitemapDate,
20
20
  sitemapDocuments
21
21
  } from "./feeds.js";
22
- import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
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
- const DEP_MAP_VERSION = 2;
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
- return Response.json({ ok: false, error: String((err && err.message) || err) }, { status: 500 });
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: ${STATIC_CSP}`]]);
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: ${REACTIVE_CSP}`]]);
1337
+ blocks.push([pattern, [` Content-Security-Policy: ${csp}`]]);
1291
1338
  }
1292
1339
  }
1293
1340
 
@@ -1417,15 +1464,17 @@ function emitAssets(assets, paths, pageHtml, warned) {
1417
1464
  fs.mkdirSync(path.dirname(out), { recursive: true });
1418
1465
  if (source.endsWith(".skin")) {
1419
1466
  const src = fs.readFileSync(source, "utf8");
1467
+ // `file` threads the source path into compileSkin so a skin authoring
1468
+ // error reports `file:line` like every other compile error.
1420
1469
  if (assets.scopedSkins.has(source)) {
1421
1470
  const rel = path.relative(paths.cwd, source).replaceAll(path.sep, "/");
1422
1471
  const scope = scopeIdFor(rel);
1423
1472
  /** @type {Set<string>} */
1424
1473
  const subjects = new Set();
1425
- fs.writeFileSync(out, compileSkin(src, { scope, subjects }));
1474
+ fs.writeFileSync(out, compileSkin(src, { scope, subjects, file: source }));
1426
1475
  warnUnusedScoped(subjects, scope, pageHtml, path.basename(source), warned);
1427
1476
  } else {
1428
- fs.writeFileSync(out, compileSkin(src));
1477
+ fs.writeFileSync(out, compileSkin(src, { file: source }));
1429
1478
  }
1430
1479
  } else {
1431
1480
  fs.copyFileSync(source, out);
@@ -1509,6 +1558,11 @@ function hasColocatedPage(file) {
1509
1558
  * STANDALONE `.skin`/`.js` (no sibling page) is a plain shelf asset the docs
1510
1559
  * promise at `/__wd/media/<path>` (`<script src="/__wd/media/lib/helper.js">`),
1511
1560
  * so it is still published.
1561
+ *
1562
+ * The publish boundary matches `emitPageAssets`: a hidden (`.`, `-`, `_`) path
1563
+ * segment is private framework input, and a symlink is never followed — its
1564
+ * target lives anywhere on the disk and copying it would publish those bytes
1565
+ * verbatim under `/__wd/media/`.
1512
1566
  * @param {Paths} paths
1513
1567
  * @returns {void}
1514
1568
  */
@@ -1519,6 +1573,7 @@ function emitShelfAssets(paths) {
1519
1573
  if (ext === ".md" || ext === ".wd") continue;
1520
1574
  if ((ext === ".skin" || ext === ".js") && hasColocatedPage(file)) continue;
1521
1575
  const rel = path.relative(paths.shelfRoot, file);
1576
+ if (hasHiddenPathSegment(rel) || fs.lstatSync(file).isSymbolicLink()) continue;
1522
1577
  const folder = ext === ".json" ? "__wd/data" : "__wd/media";
1523
1578
  const out = path.join(paths.distRoot, folder, rel);
1524
1579
  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, or an opener with a closer.
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: "line",
101
- syntax: '@include /partial.wd [with key="value" …]',
101
+ kind: "either",
102
+ syntax:
103
+ '@include /partial.wd [with key="value" …] | @include /partial.wd [with …] …children… @endinclude',
102
104
  description:
103
- "Inline another .wd/.md file from site/pages or site/_, passing optional scope args.",
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
@@ -413,7 +413,10 @@ function startDevServer({ cwd, log, warn, error }) {
413
413
  }
414
414
  // Serverless parity: run a project `api/*.js` function if the path matches,
415
415
  // exactly as Vercel/Cloudflare would in production; otherwise serve static. A
416
- // throw on either path (e.g. a stat/read error) becomes a clean 500.
416
+ // throw on either path (e.g. a stat/read error) becomes a clean 500. Dev
417
+ // DELIBERATELY echoes the stack and the api runner keeps real error messages
418
+ // (the `discloseErrors` default) — this is the author's own machine; `serve`
419
+ // is the production-shaped path and discloses nothing.
417
420
  handleApiRequest({ apiDir, req, res })
418
421
  .then((handled) => {
419
422
  if (!handled) serveDev(distRoot, url, res, draftRoutes(cwd), () => lastBuildError);
@@ -473,15 +476,20 @@ function startPreviewServer({ cwd, log, error }) {
473
476
  const server = http.createServer((req, res) => {
474
477
  const acceptEncoding = req.headers["accept-encoding"];
475
478
  // Serverless parity, exactly as in dev: run a project api/*.js function when
476
- // the path matches; otherwise serve static (compressed when negotiated). A
477
- // throw on either path (e.g. a malformed dynamic segment) becomes a clean 500.
478
- handleApiRequest({ apiDir, req, res, acceptEncoding })
479
+ // the path matches; otherwise serve static (compressed when negotiated).
480
+ // Unlike dev, serve mirrors PRODUCTION disclosure rules: the api runner gets
481
+ // `discloseErrors: false` (the client sees "Internal error", same shape as
482
+ // the generated Cloudflare worker), a malformed dynamic segment is a clean
483
+ // 400 inside the runner, and an unexpected throw here is logged server-side
484
+ // but never echoed — a stack trace leaks paths and internals to the client.
485
+ handleApiRequest({ apiDir, req, res, acceptEncoding, discloseErrors: false })
479
486
  .then((handled) => {
480
487
  if (!handled) serve(distRoot, req.url || "/", res, { acceptEncoding });
481
488
  })
482
489
  .catch((err) => {
490
+ error(err instanceof Error && err.stack ? err.stack : String(err));
483
491
  res.writeHead(500, { "content-type": "text/plain" });
484
- res.end((err instanceof Error && err.stack) || String(err));
492
+ res.end("Internal Server Error");
485
493
  });
486
494
  });
487
495
  return new Promise((resolve) => {
@@ -604,7 +612,7 @@ Authoring:
604
612
  * An incremental (dev `--changed`) build reports what it actually rebuilt —
605
613
  * `Rebuilt 2 of 14 routes (/blog/, /blog/hello/) into dist` — naming the routes
606
614
  * 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
615
+ * @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
616
  * @param {string} relativeRoot `dist` relative to cwd, for the message tail.
609
617
  * @returns {string}
610
618
  */
@@ -623,6 +631,10 @@ export function buildSummary(result, relativeRoot) {
623
631
  `image variants (${result.images.generated} generated, ${result.images.cached} cached)`
624
632
  );
625
633
  }
634
+ // Font post-pass (2.10.0): same rule — report only when subsets materialized.
635
+ if (result.fonts && result.fonts.subsetted + result.fonts.cached > 0) {
636
+ parts.push(`fonts (${result.fonts.subsetted} subsetted, ${result.fonts.cached} cached)`);
637
+ }
626
638
  return `${parts.join(", ")} into ${relativeRoot}`;
627
639
  }
628
640
 
@@ -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
- push(handleInclude(line, ctx, i));
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.
@@ -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(),
@@ -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
  // ---------------------------------------------------------------------------