@zvndev/darkmown 2.8.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -10
- package/CHANGELOG.md +17 -0
- package/README.md +117 -16
- package/docs/spec-alignment.md +10 -0
- package/grammar/wd-directives.gbnf +1 -1
- package/package.json +11 -3
- package/site/pages/benchmarks/index.skin +67 -0
- package/site/pages/benchmarks/index.wd +126 -0
- package/site/pages/docs/index.wd +48 -3
- package/site/pages/index.wd +1 -0
- package/src/builder.js +140 -20
- package/src/catalog.js +13 -4
- package/src/cli.js +11 -1
- package/src/compiler/body.js +30 -1
- package/src/compiler/context.js +19 -1
- package/src/compiler/includes.js +44 -1
- package/src/compiler/markdown.js +35 -12
- package/src/compiler/media.js +25 -0
- package/src/compiler/page.js +25 -4
- package/src/compiler/structure.js +272 -4
- package/src/compiler.js +13 -3
- package/src/errors.js +24 -0
- package/src/font-subset-worker.js +131 -0
- package/src/font-subset.js +457 -0
- package/src/grammar.js +3 -2
- package/src/headers.js +28 -1
- package/src/image-variant-resize.js +71 -0
- package/src/image-variants.js +294 -0
- package/src/statics.js +22 -14
- package/types/builder.d.ts +34 -1
- package/types/catalog.d.ts +3 -2
- package/types/compiler/context.d.ts +41 -0
- package/types/compiler/includes.d.ts +23 -0
- package/types/compiler/page.d.ts +9 -3
- package/types/compiler/structure.d.ts +53 -1
- package/types/compiler.d.ts +5 -2
- package/types/font-subset.d.ts +140 -0
- package/types/headers.d.ts +14 -0
- package/types/image-variants.d.ts +131 -0
|
@@ -0,0 +1,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,
|
|
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
|
|
12
|
-
* with the `_headers` / `vercel.json` structure the same build writes
|
|
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":
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
|
55
|
-
if (!route) return
|
|
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
|
-
|
|
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");
|
package/types/builder.d.ts
CHANGED
|
@@ -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
|
/**
|
package/types/catalog.d.ts
CHANGED
|
@@ -107,9 +107,10 @@ export type DirectiveEntry = {
|
|
|
107
107
|
*/
|
|
108
108
|
name: string;
|
|
109
109
|
/**
|
|
110
|
-
* A single line,
|
|
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;
|
package/types/compiler/page.d.ts
CHANGED
|
@@ -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
|