@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.
@@ -0,0 +1,294 @@
1
+ // ---------------------------------------------------------------------------
2
+ // BUILD-TIME RESPONSIVE IMAGE VARIANTS (2.9.0).
3
+ //
4
+ // The 2.8.0 `@<width>` convention wires srcset entries for variants the AUTHOR
5
+ // already made; this pre-pass makes them itself. Before routes compile, the
6
+ // builder scans `site/pages` for raster photos (`.jpg`/`.jpeg`/`.png`/`.webp`),
7
+ // and for each one large enough to be worth it writes resized `name@<width>.ext`
8
+ // siblings into `dist/` at their final served paths and hands the compiler a
9
+ // map of what was generated, so markdown.js's srcset wiring treats a generated
10
+ // variant exactly like an author-provided one. The author convention still
11
+ // wins: a `name@640.jpg` already in the source tree is never overwritten, and
12
+ // that width is simply not generated.
13
+ //
14
+ // Resizing needs sharp, and sharp is an OPTIONAL dependency, so every sharp
15
+ // touch is quarantined behind a seam: the actual resize runs in a child
16
+ // process (src/image-variant-resize.js) that dynamic-imports sharp and reports
17
+ // `unavailable` instead of throwing when it is absent. Without sharp the build
18
+ // is byte-identical to 2.8.0 plus ONE hint naming the images that could have
19
+ // gained variants. A corrupt image or a sharp failure on one file is a hint
20
+ // for that file; the build never fails over an image.
21
+ //
22
+ // Resizes are cached in `node_modules/.cache/darkmown-images/` keyed by
23
+ // content hash + width, so a second build does zero sharp work for unchanged
24
+ // images (and needs no sharp at all when everything is cached). The cache
25
+ // lives under node_modules, which every project's .gitignore already excludes.
26
+ //
27
+ // The build is synchronous, sharp is not — that is the whole reason for the
28
+ // child process. `buildSite` stays a sync API; one `spawnSync` per build runs
29
+ // all pending resizes, and a fully cached build spawns nothing.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ import { spawnSync } from "node:child_process";
33
+ import crypto from "node:crypto";
34
+ import fs from "node:fs";
35
+ import os from "node:os";
36
+ import path from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+ import { imageSize } from "./compiler/image-size.js";
39
+
40
+ /**
41
+ * @typedef {import("./config.js").Paths} Paths
42
+ */
43
+
44
+ /**
45
+ * One resize handed to the worker: shrink `source` to `width` pixels wide and
46
+ * write it (in its own format) to `out`, the cache path.
47
+ * @typedef {object} ResizeJob
48
+ * @property {string} source Absolute path of the original image.
49
+ * @property {string} out Absolute cache path the resized variant is written to.
50
+ * @property {number} width Target width in pixels (always below the intrinsic).
51
+ * @property {"jpeg" | "png" | "webp"} format Output encoder, from the source extension.
52
+ */
53
+
54
+ /**
55
+ * What the worker reports back. `unavailable` means sharp could not be loaded
56
+ * at all (not installed, or a broken native binding) — every pending image
57
+ * then ships without variants. `failed` carries one entry per image that
58
+ * sharp itself rejected (corrupt payload, unsupported encoding).
59
+ * @typedef {object} ResizeResult
60
+ * @property {boolean} unavailable
61
+ * @property {{ file: string, width: number, error: string }[]} failed
62
+ */
63
+
64
+ /**
65
+ * The pre-pass result: `variants` is the map the compiler's srcset wiring
66
+ * consults (original's ABSOLUTE source path → sorted widths generated into
67
+ * dist), and the counts feed the build summary. `unavailable` marks the
68
+ * sharp-absent build (which is otherwise identical to not having the feature).
69
+ * @typedef {object} ImageVariantPlan
70
+ * @property {Map<string, number[]>} variants
71
+ * @property {number} generated Variants resized this build.
72
+ * @property {number} cached Variants served from the on-disk cache.
73
+ * @property {boolean} unavailable sharp could not be loaded.
74
+ */
75
+
76
+ /** The widths generated by default, both comfortably under a typical source. */
77
+ export const DEFAULT_VARIANT_WIDTHS = [640, 1280];
78
+
79
+ /** The raster formats sharp resizes; everything else (svg, gif, avif) passes through. */
80
+ const RASTER_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
81
+
82
+ /** An image at or below this width is already small everywhere — skip it silently. */
83
+ const SMALL_IMAGE_MAX_WIDTH = 700;
84
+
85
+ /** The cache root, relative to the project. Under node_modules, so already ignored. */
86
+ const CACHE_DIR = "node_modules/.cache/darkmown-images";
87
+
88
+ const WORKER_PATH = path.join(
89
+ path.dirname(fileURLToPath(import.meta.url)),
90
+ "image-variant-resize.js"
91
+ );
92
+
93
+ /**
94
+ * Scan `site/pages` for raster images and generate `@<width>` variants into
95
+ * `dist`, returning the variant map the compiler wires into srcsets.
96
+ *
97
+ * Skips, none of them errors: `@<width>` variant files themselves (they are
98
+ * output, not sources), hidden-path and symlinked files (mirroring
99
+ * `emitPageAssets`, which would never publish them), images at or under 700px
100
+ * wide (already small), widths at or above the intrinsic size (never
101
+ * upscale), and widths the author already provided (their file wins). An image
102
+ * whose dimensions cannot be read, and one sharp fails on, get a hint naming
103
+ * the file and ship without variants.
104
+ * @param {Paths} paths
105
+ * @param {{ widths?: number[], cacheDir?: string, quiet?: boolean,
106
+ * runJobs?: (jobs: ResizeJob[]) => ResizeResult }} [options]
107
+ * `widths` overrides {@link DEFAULT_VARIANT_WIDTHS}. `quiet` suppresses the
108
+ * hints (dev-server rebuilds, which already printed them at the initial
109
+ * build). `runJobs` is the resize seam — the default spawns the worker
110
+ * process; tests inject fakes for the unavailable/crash paths.
111
+ * @returns {ImageVariantPlan}
112
+ */
113
+ export function generateImageVariants(paths, options = {}) {
114
+ const widths = options.widths ?? DEFAULT_VARIANT_WIDTHS;
115
+ const cacheDir = options.cacheDir ?? path.join(paths.cwd, CACHE_DIR);
116
+ const runJobs = options.runJobs ?? runResizeJobs;
117
+ const hint = options.quiet ? () => {} : (/** @type {string} */ m) => console.warn(`hint: ${m}`);
118
+ /** @type {Map<string, number[]>} */
119
+ const variants = new Map();
120
+ const stats = { generated: 0, cached: 0, unavailable: false };
121
+
122
+ /** @type {ResizeJob[]} */
123
+ const pending = [];
124
+ /** @type {{ job: ResizeJob, distOut: string }[]} */
125
+ const waiting = [];
126
+ /** @type {string[]} Project-relative paths of images with uncached work, for the sharp-absent hint. */
127
+ const needingSharp = [];
128
+
129
+ /**
130
+ * Record one materialized variant: copy it from the cache to its served
131
+ * path and add its width to the compiler map.
132
+ * @param {string} source Absolute path of the original.
133
+ * @param {number} width
134
+ * @param {string} cacheFile
135
+ * @param {string} distOut
136
+ */
137
+ const emitVariant = (source, width, cacheFile, distOut) => {
138
+ fs.mkdirSync(path.dirname(distOut), { recursive: true });
139
+ fs.copyFileSync(cacheFile, distOut);
140
+ const list = variants.get(source) ?? [];
141
+ list.push(width);
142
+ variants.set(source, list);
143
+ };
144
+
145
+ if (fs.existsSync(paths.routesRoot)) {
146
+ for (const file of walk(paths.routesRoot)) {
147
+ const ext = path.extname(file).toLowerCase();
148
+ if (!RASTER_EXTENSIONS.has(ext)) continue;
149
+ const stem = path.basename(file, ext);
150
+ // A `name@640.jpg` file IS a variant (the author's, or one a previous
151
+ // run was asked to leave alone) — never treated as a source.
152
+ if (/@\d+$/.test(stem)) continue;
153
+ const rel = path.relative(paths.routesRoot, file);
154
+ if (hasHiddenPathSegment(rel) || fs.lstatSync(file).isSymbolicLink()) continue;
155
+ /** @type {Uint8Array} */
156
+ let bytes;
157
+ try {
158
+ bytes = fs.readFileSync(file);
159
+ } catch {
160
+ hint(`no responsive variants for ${relPosix(paths, file)} — the file could not be read.`);
161
+ continue;
162
+ }
163
+ const intrinsic = imageSize(bytes);
164
+ if (!intrinsic) {
165
+ hint(
166
+ `no responsive variants for ${relPosix(paths, file)} — its dimensions could not be read ` +
167
+ "(corrupt or unsupported image). The original ships as-is."
168
+ );
169
+ continue;
170
+ }
171
+ if (intrinsic.width <= SMALL_IMAGE_MAX_WIDTH) continue;
172
+ const hash = crypto.createHash("sha256").update(bytes).digest("hex").slice(0, 24);
173
+ const targets = widths.filter(
174
+ (w) =>
175
+ w < intrinsic.width && !fs.existsSync(path.join(path.dirname(file), `${stem}@${w}${ext}`))
176
+ );
177
+ if (targets.length === 0) continue;
178
+ /** @type {"jpeg" | "png" | "webp"} */
179
+ const format = ext === ".png" ? "png" : ext === ".webp" ? "webp" : "jpeg";
180
+ for (const width of targets) {
181
+ const cacheFile = path.join(cacheDir, `${hash}@${width}${ext}`);
182
+ const distOut = path.join(paths.distRoot, path.dirname(rel), `${stem}@${width}${ext}`);
183
+ if (fs.existsSync(cacheFile)) {
184
+ emitVariant(file, width, cacheFile, distOut);
185
+ stats.cached++;
186
+ } else {
187
+ const job = { source: file, out: cacheFile, width, format };
188
+ pending.push(job);
189
+ waiting.push({ job, distOut });
190
+ if (!needingSharp.includes(relPosix(paths, file)))
191
+ needingSharp.push(relPosix(paths, file));
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ if (pending.length > 0) {
198
+ const result = runJobs(pending);
199
+ if (result.unavailable) {
200
+ stats.unavailable = true;
201
+ hint(
202
+ `${needingSharp.length} image${needingSharp.length === 1 ? "" : "s"} could gain responsive ` +
203
+ `variants (${needingSharp.join(", ")}). Run \`npm i -D sharp\` to generate them at build time.`
204
+ );
205
+ } else {
206
+ const failed = new Map(result.failed.map((f) => [`${f.file}@${f.width}`, f.error]));
207
+ for (const { job, distOut } of waiting) {
208
+ const error = failed.get(`${job.source}@${job.width}`);
209
+ if (error !== undefined) {
210
+ hint(
211
+ `no ${job.width}px variant for ${relPosix(paths, job.source)} — sharp failed: ${error}. ` +
212
+ "The image ships without that variant."
213
+ );
214
+ continue;
215
+ }
216
+ emitVariant(job.source, job.width, job.out, distOut);
217
+ stats.generated++;
218
+ }
219
+ }
220
+ }
221
+
222
+ for (const list of variants.values()) list.sort((a, b) => a - b);
223
+ return { variants, ...stats };
224
+ }
225
+
226
+ /**
227
+ * The default resize seam: run every pending job in ONE child process (the
228
+ * build is synchronous; sharp is not), communicating through temp JSON files
229
+ * rather than stdio so a large job list never hits a pipe buffer. The worker
230
+ * always writes a results file — a missing or unparsable one means the child
231
+ * itself died, which is reported as `unavailable` (the build then behaves as
232
+ * if sharp were absent: a hint, no variants, no failure).
233
+ * @param {ResizeJob[]} jobs
234
+ * @param {(cmd: string, args: string[], opts: object) => unknown} [spawn]
235
+ * Injectable for tests; the real one is `spawnSync`.
236
+ * @returns {ResizeResult}
237
+ */
238
+ export function runResizeJobs(jobs, spawn = /** @type {any} */ (spawnSync)) {
239
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wd-img-variants-"));
240
+ try {
241
+ const jobsFile = path.join(dir, "jobs.json");
242
+ const resultsFile = path.join(dir, "results.json");
243
+ fs.writeFileSync(jobsFile, JSON.stringify(jobs));
244
+ spawn(process.execPath, [WORKER_PATH, jobsFile, resultsFile], {
245
+ stdio: ["ignore", "ignore", "inherit"]
246
+ });
247
+ if (!fs.existsSync(resultsFile)) return { unavailable: true, failed: [] };
248
+ try {
249
+ return JSON.parse(fs.readFileSync(resultsFile, "utf8"));
250
+ } catch {
251
+ return { unavailable: true, failed: [] };
252
+ }
253
+ } finally {
254
+ fs.rmSync(dir, { recursive: true, force: true });
255
+ }
256
+ }
257
+
258
+ /**
259
+ * A project-relative, POSIX-separated path for hint messages.
260
+ * @param {Paths} paths
261
+ * @param {string} file Absolute path.
262
+ * @returns {string}
263
+ */
264
+ function relPosix(paths, file) {
265
+ return path.relative(paths.cwd, file).replaceAll(path.sep, "/");
266
+ }
267
+
268
+ /**
269
+ * Match the router's (and `emitPageAssets`') hidden-path convention: anything
270
+ * below a `.`, `-`, or `_` segment is private framework input, not public
271
+ * output — variants for it would never be served, so it is not scanned.
272
+ * @param {string} rel
273
+ * @returns {boolean}
274
+ */
275
+ function hasHiddenPathSegment(rel) {
276
+ return rel.split(path.sep).some((segment) => /^[._-]/.test(segment));
277
+ }
278
+
279
+ /**
280
+ * All files under `dir`, recursively. Mirrors the builder's own walker; kept
281
+ * local so this module does not import the builder (which imports this).
282
+ * @param {string} dir
283
+ * @returns {string[]}
284
+ */
285
+ function walk(dir) {
286
+ /** @type {string[]} */
287
+ const files = [];
288
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
289
+ const abs = path.join(dir, entry.name);
290
+ if (entry.isDirectory()) files.push(...walk(abs));
291
+ else files.push(abs);
292
+ }
293
+ return files;
294
+ }
package/src/statics.js CHANGED
@@ -2,14 +2,16 @@ import fs from "node:fs";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
4
  import zlib from "node:zlib";
5
- import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
5
+ import { BASE_SECURITY_HEADERS, REACTIVE_CSP, routeCsp } from "./headers.js";
6
6
 
7
7
  /**
8
8
  * The security headers (including the right CSP variant) for an HTML response.
9
9
  * Mirrors what production ships. Since 2.1 both variants are eval-free (the
10
10
  * reactive runtime walks a validated AST, not `new Function`), so a reactive and a
11
- * static route resolve to the same CSP — the per-route selection is kept for parity
12
- * with the `_headers` / `vercel.json` structure the same build writes. Anything not
11
+ * static route resolve to the same base CSP — the per-route selection is kept for
12
+ * parity with the `_headers` / `vercel.json` structure the same build writes, and
13
+ * it is what lets a route's own `:embed` hosts (recorded on its manifest entry as
14
+ * `assets.embedHosts`) widen `frame-src` for exactly that route. Anything not
13
15
  * found in `routes.json` (an unknown route, a 404, a missing manifest) falls back
14
16
  * to the reactive CSP, exactly like the production `_headers` catch-all block.
15
17
  * @param {string} distRoot Absolute path to the build output directory.
@@ -17,9 +19,12 @@ import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
17
19
  * @returns {Record<string, string>}
18
20
  */
19
21
  function htmlSecurityHeaders(distRoot, url) {
22
+ const entry = routeEntry(distRoot, routeOf(url));
20
23
  return {
21
24
  ...BASE_SECURITY_HEADERS,
22
- "Content-Security-Policy": routeIsReactive(distRoot, routeOf(url)) ? REACTIVE_CSP : STATIC_CSP
25
+ "Content-Security-Policy": entry
26
+ ? routeCsp(Boolean(entry.assets?.runtime), entry.assets?.embedHosts ?? [])
27
+ : REACTIVE_CSP
23
28
  };
24
29
  }
25
30
 
@@ -43,24 +48,23 @@ function routeOf(url) {
43
48
  }
44
49
 
45
50
  /**
46
- * Whether the route ships the reactive runtime, per the build's `routes.json`.
47
- * Defaults to `true` (reactive) when the route is absent or the manifest can't be
48
- * read — matching the production `_headers` catch-all, so an unknown route is
49
- * never accidentally served the stricter policy.
51
+ * The route's own manifest entry from the build's `routes.json`, or null when
52
+ * the route is absent or the manifest can't be read — in which case the caller
53
+ * falls back to the reactive CSP, matching the production `_headers` catch-all,
54
+ * so an unknown route is never accidentally served the stricter policy.
50
55
  * @param {string} distRoot
51
56
  * @param {string | null} route
52
- * @returns {boolean}
57
+ * @returns {{ route: string, assets?: { runtime?: boolean, embedHosts?: string[] } } | null}
53
58
  */
54
- function routeIsReactive(distRoot, route) {
55
- if (!route) return true;
59
+ function routeEntry(distRoot, route) {
60
+ if (!route) return null;
56
61
  try {
57
62
  const manifest = JSON.parse(fs.readFileSync(path.join(distRoot, "routes.json"), "utf8"));
58
- const entry = manifest.find((/** @type {{ route: string }} */ e) => e.route === route);
59
- if (entry) return Boolean(entry.assets?.runtime);
63
+ return manifest.find((/** @type {{ route: string }} */ e) => e.route === route) ?? null;
60
64
  } catch {
61
65
  /* no/unreadable manifest → reactive default (the catch-all policy) */
66
+ return null;
62
67
  }
63
- return true;
64
68
  }
65
69
 
66
70
  /**
@@ -256,6 +260,10 @@ export function resolvePublicFile(distRoot, url) {
256
260
  } catch {
257
261
  return null;
258
262
  }
263
+ // A decoded NUL (%00) is a valid string here but every fs call on the
264
+ // resulting path throws ERR_INVALID_ARG_VALUE out of the request handler,
265
+ // killing the server. It can never name a built file, so it is a miss.
266
+ if (cleanUrl.includes("\0")) return null;
259
267
  const base = cleanUrl.startsWith("/__wd/")
260
268
  ? cleanUrl
261
269
  : path.join(cleanUrl, path.extname(cleanUrl) ? "" : "index.html");
@@ -53,6 +53,12 @@ export function stripRuntimeComments(source: string): string;
53
53
  *
54
54
  * Clean URLs mean a route like `/docs/` is served at both `/docs` and `/docs/`,
55
55
  * so each static route emits a path glob (`/docs`, `/docs/*`) that covers both.
56
+ *
57
+ * A page whose `:embed`s frame hosts beyond the base policy (a booking widget,
58
+ * a map) carries those hosts on its manifest entry (`assets.embedHosts`), and
59
+ * its block — static or reactive — states the unioned per-route policy
60
+ * ({@link routeCsp}). A reactive route with embed hosts can no longer inherit
61
+ * the catch-all, so it always gets its own block.
56
62
  * @param {RouteManifestEntry[]} manifest
57
63
  * @returns {string}
58
64
  */
@@ -68,7 +74,10 @@ export function renderCloudflareHeaders(manifest: RouteManifestEntry[]): string;
68
74
  * @typedef {object} RouteManifestEntry
69
75
  * @property {string} route Public route path.
70
76
  * @property {string} file Source file path, relative to cwd, POSIX-separated.
71
- * @property {{ skins: string[], scripts: string[], runtime: boolean }} assets
77
+ * @property {{ skins: string[], scripts: string[], runtime: boolean, embedHosts?: string[] }} assets
78
+ * `embedHosts` is present only when the page's `:embed`s frame at least one
79
+ * source the base CSP does not already cover — absent keeps a no-embed
80
+ * site's routes.json byte-identical.
72
81
  */
73
82
  /**
74
83
  * One source route's record in the dev dependency map (`dist/.wd-dev-deps.json`),
@@ -98,6 +107,24 @@ export type BuildResult = {
98
107
  sitemap: number | null;
99
108
  rss: number | null;
100
109
  };
110
+ /**
111
+ * Responsive-variant pre-pass counts (2.9.0): freshly resized, cache-served,
112
+ * and whether sharp could not be loaded at all.
113
+ */
114
+ images: {
115
+ generated: number;
116
+ cached: number;
117
+ unavailable: boolean;
118
+ };
119
+ /**
120
+ * Font-subset post-pass counts (2.10.0): freshly subsetted, cache-served, and
121
+ * whether harfbuzzjs/wawoff2 could not be loaded at all.
122
+ */
123
+ fonts: {
124
+ subsetted: number;
125
+ cached: number;
126
+ unavailable: boolean;
127
+ };
101
128
  incremental?: {
102
129
  changed: string[];
103
130
  rebuilt: string[];
@@ -159,10 +186,16 @@ export type RouteManifestEntry = {
159
186
  * Source file path, relative to cwd, POSIX-separated.
160
187
  */
161
188
  file: string;
189
+ /**
190
+ * `embedHosts` is present only when the page's `:embed`s frame at least one
191
+ * source the base CSP does not already cover — absent keeps a no-embed
192
+ * site's routes.json byte-identical.
193
+ */
162
194
  assets: {
163
195
  skins: string[];
164
196
  scripts: string[];
165
197
  runtime: boolean;
198
+ embedHosts?: string[];
166
199
  };
167
200
  };
168
201
  /**
@@ -107,9 +107,10 @@ export type DirectiveEntry = {
107
107
  */
108
108
  name: string;
109
109
  /**
110
- * A single line, or an opener with a closer.
110
+ * A single line, an opener with a
111
+ * closer, or a line that may optionally open a block (`@include … @endinclude`).
111
112
  */
112
- kind: "line" | "block";
113
+ kind: "line" | "block" | "either";
113
114
  /**
114
115
  * One-line schematic (`[…]` = optional, `<…>` = slot).
115
116
  */
@@ -26,6 +26,12 @@
26
26
  * that opted into scoping (`scoped` first line). The builder emits these with
27
27
  * the scope attribute and runs the unused-selector warning against the stamped
28
28
  * subtree; the HTML stamp uses them to scope the matching page/include subtree.
29
+ * @property {Set<string>} embedHosts Frame sources (origins like
30
+ * `https://app.example.com`, or `'self'` for a same-origin embed) of every
31
+ * `:embed` on the page, collected AFTER the YouTube/Vimeo rewrite. The builder
32
+ * records them on the route manifest entry so the per-route CSP's `frame-src`
33
+ * authorizes exactly the hosts this page frames (see `routeCsp` in
34
+ * src/headers.js).
29
35
  */
30
36
  /**
31
37
  * The pagination intent a `@loop … paginate N` records during compile, so the
@@ -66,6 +72,11 @@
66
72
  * read threads through — the real filesystem by default, or an in-memory map
67
73
  * for `compileFromMemory`. Set once by `createCompilation` and reached via
68
74
  * `ctx.comp.reader` in the directive handlers (includes, loop data).
75
+ * @property {Map<string, number[]> | null} imageVariants Build-time-GENERATED
76
+ * responsive variants (2.9.0): the original image's absolute source path →
77
+ * the sorted `@<width>` sizes the builder's pre-pass resized into dist. Null
78
+ * outside a builder-driven compile (e.g. `compileFromMemory`), where the
79
+ * srcset wiring probes author-provided variants only.
69
80
  * @property {Symbol[]} symbols Every declared thing this compile saw, in source
70
81
  * order, recorded by {@link recordSymbol} from the handler that parsed it.
71
82
  * Surfaced on {@link CompiledPage} so a tool layer can answer "what is in this
@@ -124,6 +135,11 @@
124
135
  * opener's `file:line` + raw text, set when descending into a loop body so the
125
136
  * reactive-nesting-depth error can point at the offending opener.
126
137
  * @property {boolean} [loopMeta] Inside a loop body, so `$index`/`$first`/… are valid.
138
+ * @property {number} [includeBlockDepth] Enclosing block-form `@include`
139
+ * CHILDREN levels (0 at page level, absent on a fresh file compile). Set
140
+ * while compiling a block's children so a block include nested past one
141
+ * level inside children is a compile error (WD616, together with the
142
+ * include-stack depth check for include bodies).
127
143
  * @property {import("markdown-it").default} [md] Markdown-it instance selected for this file.
128
144
  * @property {(lines: string[], ctx: Ctx) => string} compileBody The body
129
145
  * dispatcher, threaded through the context so block handlers (`:if`, `:::`,
@@ -405,6 +421,15 @@ export type Assets = {
405
421
  * subtree; the HTML stamp uses them to scope the matching page/include subtree.
406
422
  */
407
423
  scopedSkins: Set<string>;
424
+ /**
425
+ * Frame sources (origins like
426
+ * `https://app.example.com`, or `'self'` for a same-origin embed) of every
427
+ * `:embed` on the page, collected AFTER the YouTube/Vimeo rewrite. The builder
428
+ * records them on the route manifest entry so the per-route CSP's `frame-src`
429
+ * authorizes exactly the hosts this page frames (see `routeCsp` in
430
+ * src/headers.js).
431
+ */
432
+ embedHosts: Set<string>;
408
433
  };
409
434
  /**
410
435
  * The pagination intent a `@loop … paginate N` records during compile, so the
@@ -488,6 +513,14 @@ export type Compilation = {
488
513
  * `ctx.comp.reader` in the directive handlers (includes, loop data).
489
514
  */
490
515
  reader: import("./reader.js").Reader;
516
+ /**
517
+ * Build-time-GENERATED
518
+ * responsive variants (2.9.0): the original image's absolute source path →
519
+ * the sorted `@<width>` sizes the builder's pre-pass resized into dist. Null
520
+ * outside a builder-driven compile (e.g. `compileFromMemory`), where the
521
+ * srcset wiring probes author-provided variants only.
522
+ */
523
+ imageVariants: Map<string, number[]> | null;
491
524
  /**
492
525
  * Every declared thing this compile saw, in source
493
526
  * order, recorded by {@link recordSymbol} from the handler that parsed it.
@@ -617,6 +650,14 @@ export type Ctx = {
617
650
  * Inside a loop body, so `$index`/`$first`/… are valid.
618
651
  */
619
652
  loopMeta?: boolean | undefined;
653
+ /**
654
+ * Enclosing block-form `@include`
655
+ * CHILDREN levels (0 at page level, absent on a fresh file compile). Set
656
+ * while compiling a block's children so a block include nested past one
657
+ * level inside children is a compile error (WD616, together with the
658
+ * include-stack depth check for include bodies).
659
+ */
660
+ includeBlockDepth?: number | undefined;
620
661
  /**
621
662
  * Markdown-it instance selected for this file.
622
663
  */
@@ -101,6 +101,15 @@ export function resolveInclude(spec: string, fromFile: string, context: Paths, a
101
101
  * @returns {boolean}
102
102
  */
103
103
  export function isAllowedInclude(file: string, context: Paths, reader?: import("./reader.js").Reader): boolean;
104
+ /**
105
+ * The compiled children HTML for the include currently compiling, or null when
106
+ * the file is not being compiled as an include at all (a top-level page) — in
107
+ * which case a `{ children }` line stays literal text. An include called
108
+ * WITHOUT a block carries "" (the slot renders empty).
109
+ * @param {import("./context.js").Ctx} ctx
110
+ * @returns {string | null}
111
+ */
112
+ export function childrenSlot(ctx: import("./context.js").Ctx): string | null;
104
113
  /**
105
114
  * Warn when a plain `.md` file contains `.wd`-only syntax that stays inert.
106
115
  * @param {string} body
@@ -109,6 +118,20 @@ export function isAllowedInclude(file: string, context: Paths, reader?: import("
109
118
  * @returns {void}
110
119
  */
111
120
  export function scanMarkdownHints(body: string, file: string, comp: Compilation): void;
121
+ /**
122
+ * Scope key carrying the compiled children HTML for the include currently
123
+ * being compiled. Control-character-prefixed so no author-visible name can
124
+ * collide with it or read it through `{ }` interpolation.
125
+ */
126
+ export const INCLUDE_CHILDREN: "\0children";
127
+ /**
128
+ * A line that is exactly the slot marker (`{ children }`, whitespace-tolerant),
129
+ * matching the spelling freedom of `{ name }` interpolation. Shared by the
130
+ * body parser (which splices the slot) and the include handler (which scans an
131
+ * include's body for whether the slot is ever used).
132
+ * @type {RegExp}
133
+ */
134
+ export const CHILDREN_SLOT_LINE: RegExp;
112
135
  export type Paths = import("./context.js").Paths;
113
136
  export type Assets = import("./context.js").Assets;
114
137
  export type Compilation = import("./context.js").Compilation;
@@ -2,7 +2,7 @@
2
2
  * Compile a page source file into a full HTML document plus its assets.
3
3
  * @param {string} file Absolute path to the source `.md`/`.wd` file.
4
4
  * @param {Paths} context Resolved project paths.
5
- * @param {{ feed?: { href: string, title: string }, site?: SiteContext, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./reader.js").Reader }} [options]
5
+ * @param {{ feed?: { href: string, title: string }, site?: SiteContext, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
6
6
  * When a site-wide RSS feed is emitted (the home page set `site_url` and the
7
7
  * site has dated posts), `feed` carries its absolute href + title so every page
8
8
  * links it. `site` carries the origin + this page's own route (and its resolved
@@ -10,7 +10,8 @@
10
10
  * build-time collection index a bare-name `@loop` resolves against; `vars` seeds
11
11
  * the document scope (the pager `page` object on a paginated route). `reader` is
12
12
  * the source reader (fs-backed by default via `src/compiler.js`; in-memory for
13
- * `compileFromMemory`).
13
+ * `compileFromMemory`). `imageVariants` is the builder's generated-variant map
14
+ * (2.9.0): original absolute source path → `@<width>` sizes resized into dist.
14
15
  * @returns {CompiledPage}
15
16
  */
16
17
  export function compilePage(file: string, context: Paths, options?: {
@@ -22,6 +23,7 @@ export function compilePage(file: string, context: Paths, options?: {
22
23
  collections?: Map<string, import("./collections.js").CollectionRow[]>;
23
24
  vars?: Record<string, unknown>;
24
25
  reader?: import("./reader.js").Reader;
26
+ imageVariants?: Map<string, number[]> | null;
25
27
  }): CompiledPage;
26
28
  /**
27
29
  * Harden every `<img>` in the assembled page body — compile-time only, zero
@@ -47,9 +49,13 @@ export function enhanceImages(html: string, paths: Paths, reader: import("./read
47
49
  * Build-time collection index a bare-name `@loop` resolves against.
48
50
  * @param {import("./reader.js").Reader} [reader] Source reader (fs-backed when
49
51
  * omitted via `src/compiler.js`; in-memory for `compileFromMemory`).
52
+ * @param {Map<string, number[]> | null} [imageVariants] Build-time-generated
53
+ * responsive variants (original's absolute source path → widths), threaded by
54
+ * the builder so the srcset wiring includes them alongside author-provided
55
+ * `@<width>` siblings.
50
56
  * @returns {CompiledDocument}
51
57
  */
52
- export function compileDocument(file: string, context: Paths, stack?: string[], vars?: Record<string, unknown>, collections?: Map<string, import("./collections.js").CollectionRow[]>, reader?: import("./reader.js").Reader): CompiledDocument;
58
+ export function compileDocument(file: string, context: Paths, stack?: string[], vars?: Record<string, unknown>, collections?: Map<string, import("./collections.js").CollectionRow[]>, reader?: import("./reader.js").Reader, imageVariants?: Map<string, number[]> | null): CompiledDocument;
53
59
  /**
54
60
  * Compile a single file: parse frontmatter, collect assets, render body.
55
61
  * @param {string} file