@zvndev/darkmown 2.8.0 → 2.9.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 +1 -1
- package/CHANGELOG.md +4 -0
- package/README.md +13 -11
- package/docs/spec-alignment.md +4 -0
- package/package.json +6 -2
- package/site/pages/docs/index.wd +1 -1
- package/src/builder.js +75 -8
- package/src/cli.js +7 -1
- package/src/compiler/context.js +6 -0
- package/src/compiler/markdown.js +35 -12
- package/src/compiler/page.js +19 -4
- package/src/compiler.js +13 -3
- package/src/image-variant-resize.js +71 -0
- package/src/image-variants.js +294 -0
- package/types/builder.d.ts +9 -0
- package/types/compiler/context.d.ts +13 -0
- package/types/compiler/page.d.ts +9 -3
- package/types/compiler.d.ts +5 -2
- package/types/image-variants.d.ts +131 -0
package/AGENTS.md
CHANGED
|
@@ -41,7 +41,7 @@ package.json
|
|
|
41
41
|
- Files/folders starting with `.`, `-`, or `_` are **hidden** from routing (drafts, partials).
|
|
42
42
|
- A `page.skin` next to `page.wd` attaches styling; a `page.js` attaches behavior. Matched by basename.
|
|
43
43
|
- **Colocated assets:** any other file under `site/pages/` (not a `.md`/`.wd` route, not a basename-matched `.skin`/`.js`) copies to `dist/` at its own path with the right content-type — so `site/pages/logo.svg` is served at `/logo.svg`. Use the `site/_/` shelf for assets shared across pages; colocate the ones a single page owns.
|
|
44
|
-
- **Responsive image variants:**
|
|
44
|
+
- **Responsive image variants:** with sharp installed (`npm i -D sharp`, an optional dependency; builds work without it), the build scans `site/pages` for raster photos (jpg/jpeg/png/webp) over 700px wide and writes resized `name@640.ext` / `name@1280.ext` variants into `dist`, wiring a `srcset` onto the Markdown image with the original kept as `src`. Only widths below the intrinsic size are generated (never upscale), variants keep the source format, and EXIF orientation is applied. **Author-provided `name@<width>.ext` exports win per width**: your file is copied verbatim and measured from its own header (a `@640` file holding 600px emits `600w`), and generation fills only the uncovered widths; other sizes are hand exports on the probe list (64, 96, 128, …, 3840). Skipped silently: images ≤700px, `@width` files, hidden/symlinked paths, svg/gif/avif, and shelf images (the generator covers page-colocated images only). Without sharp the build behaves as before plus one `npm i -D sharp` hint per build; a corrupt image or a sharp failure on one file is a hint naming the file, never a build failure. Resizes cache in `node_modules/.cache/darkmown-images/` (content hash + width + format), so rebuilds and dev reloads do zero sharp work for unchanged images. Never wired: bound srcs (``), remote/`data:`/query-hash srcs, page-relative or extensionless srcs, and raw-HTML `<img>` tags; `sizes` is never emitted (raw `<img srcset sizes>` on an `html: true` page is the way). `.md` pages get the width/height/lazy hardening but no srcset. The first image on the page stays eager with `fetchpriority="high"`, the rest lazy-load, and author-set attributes always win.
|
|
45
45
|
- Shelf `.json` files publish to `/__wd/data/<name>`; every other shelf file publishes to `/__wd/media/<path>`.
|
|
46
46
|
|
|
47
47
|
Commands: `darkmown dev`, `darkmown build [--drafts]`, `darkmown validate` (compile-check without writing `dist`), `darkmown serve` (previews `dist`, runs `api/` functions like dev, and compresses text responses), `darkmown deploy <vercel|cloudflare>`, `darkmown catalog [--llms|--llms-full]`.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
|
|
4
4
|
|
|
5
|
+
## 2.9.0 (2026-10-13)
|
|
6
|
+
|
|
7
|
+
- **Authoring/Assets: responsive image variants are generated at build time.** With [sharp](https://sharp.pixelplumbing.com/) installed (`npm i -D sharp`, declared as an `optionalDependency`, so `dependencies` stays empty and every build works without it), the builder scans `site/pages` for raster photos (jpg/jpeg/png/webp) wider than 700px and writes resized `name@640.ext` / `name@1280.ext` siblings into `dist`, handing the compiler a variant map that wires the `srcset` exactly like author-provided variants: the original stays the `src` fallback and joins the srcset at its intrinsic width. Only widths below the intrinsic size are generated (never upscale), variants keep the source format (webp and jpeg at quality 82, jpeg progressive; png stays png at maximum compression), and EXIF orientation is applied before resizing, so rotated phone photos come out upright. **BEHAVIOR CHANGE: a build with sharp installed now generates variants automatically** where before it shipped the original alone; **without sharp, nothing changes** except one hint per build naming the images that could gain variants (`Run npm i -D sharp to generate them at build time.`). The 2.8.0 `@width` convention becomes the override: an author-provided `name@640.ext` is copied verbatim, measured from its own header, and never overwritten, and generation fills only the widths the author left uncovered. Skipped silently: images at or under 700px, `@width` files themselves, hidden and symlinked paths, svg/gif/avif, and shelf images under `site/_`. A corrupt image (dimensions unreadable) or a sharp failure on one file prints a hint naming that file; the build never fails because of an image. Resizes cache in `node_modules/.cache/darkmown-images/` keyed by content hash + width + format (already gitignored with `node_modules`), so a second build does zero sharp work and a fully cached build never loads sharp; dev rebuilds recompute the variant map from the cache, and changing an image forces a full rebuild, exactly as before. The build summary gains an `image variants (N generated, M cached)` clause whenever variants materialized. The build is synchronous and sharp is not, so the resizes run in one child process per build, and a fully cached build spawns nothing.
|
|
8
|
+
|
|
5
9
|
## 2.8.0 (2026-10-13)
|
|
6
10
|
|
|
7
11
|
A release from a real client-site rebuild: a 67-route marketing site rebuilt in Darkmown, with every friction point the rebuild reported either fixed or written down as a recipe. The `.skin` language learned `@font-face` and `@keyframes`, frontmatter gained favicon, shared-skin, and discovery opt-out keys, format pipes now fold inside raw HTML, and `darkmown serve` reached parity with dev for `api/` functions while adding response compression. One deliberate behavior change (the stylesheet cascade order) is called out below with its migration note. Three new error codes (`WD017`, `WD706`, `WD707`), no breaking API change.
|
package/README.md
CHANGED
|
@@ -215,21 +215,23 @@ Reference them with a normal URL: ``. The preview/`
|
|
|
215
215
|
|
|
216
216
|
**Images are hardened at compile time.** Every `<img>` the compiler emits is stamped with its intrinsic `width`/`height` (read from the source file, so the browser reserves space and the page doesn't reflow as images decode), `decoding="async"`, and a load-priority split — the first image on the page stays eager with `fetchpriority="high"` (the LCP candidate), the rest get `loading="lazy"`. Author-set attributes always win, and remote or unreadable sources just skip the dimensions. The compiler **measures**, it doesn't resize — keep source images web-sized (modern formats, sensible dimensions).
|
|
217
217
|
|
|
218
|
-
**Responsive variants
|
|
218
|
+
**Responsive variants, generated at build time.** Drop one photo into `site/pages` and the build does the rest: with [sharp](https://sharp.pixelplumbing.com/) installed (`npm i -D sharp`, an optional dependency; every build works without it), each raster image large enough to matter is resized to the default widths **640 and 1280**, written into `dist/` as `name@<width>.ext` siblings, and the Markdown image that references the original gains a `srcset`:
|
|
219
219
|
|
|
220
220
|
```html
|
|
221
|
-
<img src="/doctors/dr-
|
|
222
|
-
srcset="/doctors/dr-
|
|
221
|
+
<img src="/doctors/dr-aurora.jpg" alt="Dr. Aurora"
|
|
222
|
+
srcset="/doctors/dr-aurora@640.jpg 640w, /doctors/dr-aurora@1280.jpg 1280w, /doctors/dr-aurora.jpg 2400w"
|
|
223
223
|
width="2400" height="1600" decoding="async" fetchpriority="high">
|
|
224
224
|
```
|
|
225
225
|
|
|
226
|
-
- **
|
|
227
|
-
- **
|
|
228
|
-
-
|
|
229
|
-
- **
|
|
230
|
-
-
|
|
231
|
-
|
|
232
|
-
-
|
|
226
|
+
- **Only widths below the intrinsic size are generated (never upscale)**, and variants keep the source format: webp re-encodes at quality 82, jpeg at quality 82 progressive, png stays png. EXIF orientation is applied before resizing, so rotated phone photos come out upright. (The build is synchronous and sharp is not, so the resizes run in one child process per build; a fully cached build spawns nothing.)
|
|
227
|
+
- **Skipped silently:** images at or under 700px wide (already small everywhere), `name@<width>.ext` files themselves (output, never sources), hidden path segments (`.`, `-`, `_`) and symlinks (matching what the build publishes), and formats that are not resized (svg, gif, avif). The generator scans `site/pages` only; shelf images under `site/_` are not resized, though author-provided shelf variants still wire through the convention below.
|
|
228
|
+
- **Without sharp, the build behaves exactly as before plus one hint per build:** `hint: 5 images could gain responsive variants (site/pages/carousel/slide-1.jpg, ...). Run npm i -D sharp to generate them at build time.` A corrupt image (dimensions unreadable) or a sharp failure on one file prints a hint naming that file, and that image ships without variants; the build never fails because of an image.
|
|
229
|
+
- **Resizes are cached in `node_modules/.cache/darkmown-images/`**, keyed by content hash + width + format. A second build does zero sharp work for unchanged images and does not even load sharp when everything is cached (cached variants ship even if sharp is later removed). The cache lives under `node_modules`, which every project's `.gitignore` already excludes. Dev rebuilds recompute the variant map from the cache (no sharp spawn), so a partially rebuilt page is byte-identical to a full rebuild, srcset included; changing an image itself forces a full rebuild, which re-runs the pre-pass.
|
|
230
|
+
- The build summary reports the pre-pass when variants materialized: `Built 14 routes, sitemap (14 urls), rss (6 posts), image variants (2 generated, 5 cached) into dist`.
|
|
231
|
+
|
|
232
|
+
**Author-provided variants win (the `@width` convention).** Export `dr-aurora@640.jpg` yourself, same directory, same extension, and the build does not touch that width: your file is copied verbatim and measured from its own header (a `@640` file really holding 600px emits `600w`). Generation fills only the widths you did not provide, and the convention picks up any width on the compiler's fixed probe list (64, 96, 128, …, 3840), so other sizes are a hand export away; an off-list width like `x@500.webp` is a documented miss, never an error. Variant files (yours or generated) are ordinary colocated assets: they copy to `dist/` at their own path, and an `@`-suffixed filename is never a route. Everything else about the wiring is unchanged: descriptor widths are measured from file headers, candidates sort ascending with the original joining at its intrinsic width, `sizes` is never emitted (write raw `<img srcset sizes>` on an `html: true` page for that), and a page with no resizable images emits exactly what it did before.
|
|
233
|
+
|
|
234
|
+
**Never wired, by design:** a bound src (`` or a loop row's ``, which the runtime repaints), remote (`https://`, protocol-relative) and `data:` sources, srcs with a query/hash, page-relative or extensionless srcs, and raw-HTML `<img>` tags. **`.md` caveat:** variant wiring runs in the `.wd` compile path, so a plain `.md` page gets the width/height/lazy hardening but no srcset.
|
|
233
235
|
|
|
234
236
|
**Or colocate an asset next to the page that uses it.** Any non-page file under `site/pages/` (anything but `.md`/`.wd` routes and the colocated `.skin`/`.js`) is copied to `dist/` at its own path, so a clean relative URL just works:
|
|
235
237
|
|
|
@@ -357,7 +359,7 @@ draft: true
|
|
|
357
359
|
|
|
358
360
|
Drafts are filtered at one point — route discovery — so nothing downstream ever sees them. This is **separate from** the permanent `.`/`-`/`_` filename hiding: a hidden name (`-notes.wd`) is private forever; `draft:` is a toggle you flip when the page is ready to ship.
|
|
359
361
|
|
|
360
|
-
The `build` summary reports what it emitted: `Built 14 routes, sitemap (14 urls), rss (6 posts) into dist
|
|
362
|
+
The `build` summary reports what it emitted: `Built 14 routes, sitemap (14 urls), rss (6 posts) into dist`, with an `image variants (2 generated, 5 cached)` clause appended whenever the image pre-pass produced or served cached responsive variants (see [Static assets](#static-assets)).
|
|
361
363
|
|
|
362
364
|
## Loops
|
|
363
365
|
|
package/docs/spec-alignment.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
This audit compares the current Darkmown implementation with the original MARROW/WhateverDown vision and the follow-up decisions made on 2026-06-11.
|
|
4
4
|
|
|
5
|
+
## 2.9.0 additions (unreleased, 2026-10-13)
|
|
6
|
+
|
|
7
|
+
- **The framework now makes the image variants itself, closing the rebuild's largest asset gap.** With the optional sharp dependency installed, `darkmown build` resizes page-colocated raster photos over 700px into 640/1280 `name@width.ext` siblings (never upscaling, EXIF-aware, cached by content hash under `node_modules/.cache/darkmown-images/` so rebuilds and dev reloads do no sharp work), the 2.8.0 author-provided `@width` convention stays the per-width override, and a build without sharp behaves exactly like 2.8.0 plus one hint.
|
|
8
|
+
|
|
5
9
|
## 2.8.0 additions: the client-site rebuild release (2026-10-13)
|
|
6
10
|
|
|
7
11
|
A 67-route marketing site rebuilt in Darkmown produced a friction report, and this release is the report answered: every item either became a feature, a coded error, or a documented recipe. Nothing in it moves the core thesis; it rounds out the authoring surface a real design system needs while keeping static pages zero-JS.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -88,6 +88,7 @@
|
|
|
88
88
|
"esbuild": "0.28.2",
|
|
89
89
|
"husky": "^9.1.7",
|
|
90
90
|
"lint-staged": "^17.0.0",
|
|
91
|
+
"sharp": "^0.35.5",
|
|
91
92
|
"typescript": "^6.0.3"
|
|
92
93
|
},
|
|
93
94
|
"repository": {
|
|
@@ -107,5 +108,8 @@
|
|
|
107
108
|
"zero-js",
|
|
108
109
|
"reactive"
|
|
109
110
|
],
|
|
110
|
-
"license": "MIT"
|
|
111
|
+
"license": "MIT",
|
|
112
|
+
"optionalDependencies": {
|
|
113
|
+
"sharp": "^0.35.5"
|
|
114
|
+
}
|
|
111
115
|
}
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -441,7 +441,7 @@ Add `transitions: true` to a page's frontmatter for **instant, flash-free naviga
|
|
|
441
441
|
|
|
442
442
|
You don't size images by hand. Every `<img>` the compiler emits gets its intrinsic `width`/`height` read from the source file (so the page doesn't reflow as images decode), `decoding="async"`, and a priority split — the first image stays eager with `fetchpriority="high"`, the rest `loading="lazy"`. Anything you set yourself wins. The compiler measures, it doesn't resize, so keep source images web-sized.
|
|
443
443
|
|
|
444
|
-
**Responsive variants.**
|
|
444
|
+
**Responsive variants, generated at build time.** With sharp installed (`npm i -D sharp`, an optional dependency; builds work without it), every raster photo in `site/pages` over 700px wide is resized to 640w and 1280w siblings in `dist/` (only widths below the intrinsic size, never upscaled, EXIF orientation applied), and the Markdown image gains a `srcset` with the original kept as `src`. Export `name@<width>.ext` yourself and your file wins that width verbatim, measured from its own header; generation fills only the widths you left uncovered, and other sizes are a hand export away (the compiler probes 64 through 3840). Skipped silently: small images, `@width` files, hidden or symlinked paths, svg/gif/avif, and shelf images (the generator covers page-colocated images only). Without sharp the build behaves as before plus one `npm i -D sharp` hint per build; a corrupt image or a sharp failure on one file is a hint naming the file, never a build failure. Resizes are cached in `node_modules/.cache/darkmown-images/` by content hash + width + format, so second builds and dev rebuilds do no resizing for unchanged images, and the build summary reports `image variants (N generated, M cached)` when variants materialized. `sizes` is never emitted (write raw `<img srcset sizes>` on an `html: true` page for that); bound (``), remote, `data:`, query/hash, page-relative, and extensionless srcs are never wired, and neither are raw-HTML `<img>` tags. A `.md` page gets the hardening but no srcset.
|
|
445
445
|
|
|
446
446
|
## Static assets
|
|
447
447
|
|
package/src/builder.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "./feeds.js";
|
|
22
22
|
import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
|
|
23
23
|
import { HIGHLIGHT_CSS } from "./highlight.js";
|
|
24
|
+
import { generateImageVariants } from "./image-variants.js";
|
|
24
25
|
import { discoverRoutes, outputPathForRoute } from "./router.js";
|
|
25
26
|
import { compileSkin, scopeIdFor } from "./skin.js";
|
|
26
27
|
|
|
@@ -105,6 +106,9 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
105
106
|
* @property {RouteManifestEntry[]} routes
|
|
106
107
|
* @property {string} distRoot
|
|
107
108
|
* @property {{ sitemap: number | null, rss: number | null }} feeds
|
|
109
|
+
* @property {{ generated: number, cached: number, unavailable: boolean }} images
|
|
110
|
+
* Responsive-variant pre-pass counts (2.9.0): freshly resized, cache-served,
|
|
111
|
+
* and whether sharp could not be loaded at all.
|
|
108
112
|
* @property {{ changed: string[], rebuilt: string[], total: number }} [incremental]
|
|
109
113
|
*/
|
|
110
114
|
|
|
@@ -119,6 +123,12 @@ function buildFull(cwd, paths, options) {
|
|
|
119
123
|
fs.rmSync(paths.distRoot, { recursive: true, force: true });
|
|
120
124
|
fs.mkdirSync(paths.distRoot, { recursive: true });
|
|
121
125
|
emitShelfAssets(paths);
|
|
126
|
+
// Responsive image variants (2.9.0): resize the large raster photos in
|
|
127
|
+
// site/pages into dist and collect the variant map BEFORE any route compiles,
|
|
128
|
+
// so the srcset wiring can reference what was generated. With the optional
|
|
129
|
+
// sharp dependency absent (or everything cache-served) this is cheap and
|
|
130
|
+
// never fails the build.
|
|
131
|
+
const images = generateImageVariants(paths, { quiet: options.quietFeedHints });
|
|
122
132
|
const routes = discoverRoutes(paths.routesRoot, { includeDrafts: options.includeDrafts });
|
|
123
133
|
// Build the collection index from the (post-draft-filter) routes — every
|
|
124
134
|
// site/pages/<name>/ subdirectory's entries become queryable rows a bare-name
|
|
@@ -152,7 +162,17 @@ function buildFull(cwd, paths, options) {
|
|
|
152
162
|
const mapRoutes = {};
|
|
153
163
|
|
|
154
164
|
for (const route of routes) {
|
|
155
|
-
const built = buildRoute(
|
|
165
|
+
const built = buildRoute(
|
|
166
|
+
route,
|
|
167
|
+
cwd,
|
|
168
|
+
paths,
|
|
169
|
+
collections,
|
|
170
|
+
feedLink,
|
|
171
|
+
warned,
|
|
172
|
+
identity,
|
|
173
|
+
titles,
|
|
174
|
+
images.variants
|
|
175
|
+
);
|
|
156
176
|
manifest.push(...built.mapEntry.pages);
|
|
157
177
|
if (built.notFoundHtml !== undefined) notFoundHtml = built.notFoundHtml;
|
|
158
178
|
for (const entry of built.mapEntry.pages) {
|
|
@@ -192,7 +212,12 @@ function buildFull(cwd, paths, options) {
|
|
|
192
212
|
|
|
193
213
|
if (options.depMap) writeDepMap(paths, feedLink, siteInputs(identity, titles), mapRoutes);
|
|
194
214
|
|
|
195
|
-
return {
|
|
215
|
+
return {
|
|
216
|
+
routes: manifest,
|
|
217
|
+
distRoot: paths.distRoot,
|
|
218
|
+
feeds,
|
|
219
|
+
images: { generated: images.generated, cached: images.cached, unavailable: images.unavailable }
|
|
220
|
+
};
|
|
196
221
|
}
|
|
197
222
|
|
|
198
223
|
/**
|
|
@@ -284,13 +309,30 @@ function buildIncremental(cwd, paths, options, changed) {
|
|
|
284
309
|
}
|
|
285
310
|
}
|
|
286
311
|
|
|
312
|
+
// Rebuilt routes must compile byte-identically to a full rebuild, so the
|
|
313
|
+
// generated-variant map the srcset wiring consults is recomputed here too.
|
|
314
|
+
// An image CHANGE never reaches this point (no dependency graph tracks
|
|
315
|
+
// images, so it already forced a full rebuild above), which makes this scan
|
|
316
|
+
// all cache hits: it refreshes dist copies and the map, and spawns nothing.
|
|
317
|
+
const images = generateImageVariants(paths, { quiet: options.quietFeedHints });
|
|
318
|
+
|
|
287
319
|
const byRoute = new Map(routes.map((route) => [route.route, route]));
|
|
288
320
|
/** @type {Set<string>} */
|
|
289
321
|
const warned = new Set();
|
|
290
322
|
const rebuilt = [...affected].sort();
|
|
291
323
|
for (const routePath of rebuilt) {
|
|
292
324
|
const route = /** @type {Route} */ (byRoute.get(routePath));
|
|
293
|
-
const built = buildRoute(
|
|
325
|
+
const built = buildRoute(
|
|
326
|
+
route,
|
|
327
|
+
cwd,
|
|
328
|
+
paths,
|
|
329
|
+
collections,
|
|
330
|
+
feedLink,
|
|
331
|
+
warned,
|
|
332
|
+
identity,
|
|
333
|
+
titles,
|
|
334
|
+
images.variants
|
|
335
|
+
);
|
|
294
336
|
// A shrunk pagination leaves stale `/page/N/` outputs behind — remove any
|
|
295
337
|
// previously-emitted page this rebuild no longer produces.
|
|
296
338
|
const kept = new Set(built.mapEntry.pages.map((entry) => entry.route));
|
|
@@ -326,6 +368,7 @@ function buildIncremental(cwd, paths, options, changed) {
|
|
|
326
368
|
routes: manifest,
|
|
327
369
|
distRoot: paths.distRoot,
|
|
328
370
|
feeds,
|
|
371
|
+
images: { generated: images.generated, cached: images.cached, unavailable: images.unavailable },
|
|
329
372
|
incremental: { changed, rebuilt, total: routes.length }
|
|
330
373
|
};
|
|
331
374
|
}
|
|
@@ -344,10 +387,30 @@ function buildIncremental(cwd, paths, options, changed) {
|
|
|
344
387
|
* @param {SiteIdentity} identity Resolved site identity (its `siteUrl` is what
|
|
345
388
|
* makes a canonical URL possible).
|
|
346
389
|
* @param {Map<string, string>} titles Route path → page title, for breadcrumbs.
|
|
390
|
+
* @param {Map<string, number[]>} imageVariants Generated responsive variants
|
|
391
|
+
* (original's absolute source path → widths), threaded into each compile.
|
|
347
392
|
* @returns {{ mapEntry: DepMapEntry, notFoundHtml: string | undefined }}
|
|
348
393
|
*/
|
|
349
|
-
function buildRoute(
|
|
350
|
-
|
|
394
|
+
function buildRoute(
|
|
395
|
+
route,
|
|
396
|
+
cwd,
|
|
397
|
+
paths,
|
|
398
|
+
collections,
|
|
399
|
+
feedLink,
|
|
400
|
+
warned,
|
|
401
|
+
identity,
|
|
402
|
+
titles,
|
|
403
|
+
imageVariants
|
|
404
|
+
) {
|
|
405
|
+
const compiled = compileRoutePages(
|
|
406
|
+
route,
|
|
407
|
+
paths,
|
|
408
|
+
collections,
|
|
409
|
+
feedLink,
|
|
410
|
+
identity,
|
|
411
|
+
titles,
|
|
412
|
+
imageVariants
|
|
413
|
+
);
|
|
351
414
|
/** @type {RouteManifestEntry[]} */
|
|
352
415
|
const entries = [];
|
|
353
416
|
/** @type {string | undefined} */
|
|
@@ -694,9 +757,11 @@ function copyChangedAsset(file, cwd, paths) {
|
|
|
694
757
|
* @param {{ href: string, title: string } | undefined} feedLink
|
|
695
758
|
* @param {SiteIdentity} identity
|
|
696
759
|
* @param {Map<string, string>} titles Route path → page title, for breadcrumbs.
|
|
760
|
+
* @param {Map<string, number[]>} imageVariants Generated responsive variants
|
|
761
|
+
* (original's absolute source path → widths) for the srcset wiring.
|
|
697
762
|
* @returns {{ pages: EmittedPage[], deps: Set<string>, collections: Set<string> }}
|
|
698
763
|
*/
|
|
699
|
-
function compileRoutePages(route, paths, collections, feedLink, identity, titles) {
|
|
764
|
+
function compileRoutePages(route, paths, collections, feedLink, identity, titles, imageVariants) {
|
|
700
765
|
/** @type {Set<string>} */
|
|
701
766
|
const deps = new Set();
|
|
702
767
|
/** @type {Set<string>} */
|
|
@@ -721,7 +786,8 @@ function compileRoutePages(route, paths, collections, feedLink, identity, titles
|
|
|
721
786
|
feed: feedLink,
|
|
722
787
|
site: siteFor(route.route),
|
|
723
788
|
collections,
|
|
724
|
-
vars: { page: pagerVars(route.route, 1, 1) }
|
|
789
|
+
vars: { page: pagerVars(route.route, 1, 1) },
|
|
790
|
+
imageVariants
|
|
725
791
|
});
|
|
726
792
|
track(first);
|
|
727
793
|
if (!first.pagination || first.pagination.total <= 1) {
|
|
@@ -746,7 +812,8 @@ function compileRoutePages(route, paths, collections, feedLink, identity, titles
|
|
|
746
812
|
feed: feedLink,
|
|
747
813
|
site: siteFor(pageRoute(route.route, n)),
|
|
748
814
|
collections,
|
|
749
|
-
vars: { page: pagerVars(route.route, n, total) }
|
|
815
|
+
vars: { page: pagerVars(route.route, n, total) },
|
|
816
|
+
imageVariants
|
|
750
817
|
});
|
|
751
818
|
track(page);
|
|
752
819
|
pages.push({
|
package/src/cli.js
CHANGED
|
@@ -604,7 +604,7 @@ Authoring:
|
|
|
604
604
|
* An incremental (dev `--changed`) build reports what it actually rebuilt —
|
|
605
605
|
* `Rebuilt 2 of 14 routes (/blog/, /blog/hello/) into dist` — naming the routes
|
|
606
606
|
* when there are few enough to read.
|
|
607
|
-
* @param {{ routes: unknown[], feeds: { sitemap: number | null, rss: number | null }, incremental?: { rebuilt: string[], total: number } }} result
|
|
607
|
+
* @param {{ routes: unknown[], feeds: { sitemap: number | null, rss: number | null }, incremental?: { rebuilt: string[], total: number }, images?: { generated: number, cached: number, unavailable: boolean } }} result
|
|
608
608
|
* @param {string} relativeRoot `dist` relative to cwd, for the message tail.
|
|
609
609
|
* @returns {string}
|
|
610
610
|
*/
|
|
@@ -617,6 +617,12 @@ export function buildSummary(result, relativeRoot) {
|
|
|
617
617
|
const parts = [`Built ${result.routes.length} routes`];
|
|
618
618
|
if (result.feeds.sitemap !== null) parts.push(`sitemap (${result.feeds.sitemap} urls)`);
|
|
619
619
|
if (result.feeds.rss !== null) parts.push(`rss (${result.feeds.rss} posts)`);
|
|
620
|
+
// Image pre-pass (2.9.0): worth reporting only when variants materialized.
|
|
621
|
+
if (result.images && result.images.generated + result.images.cached > 0) {
|
|
622
|
+
parts.push(
|
|
623
|
+
`image variants (${result.images.generated} generated, ${result.images.cached} cached)`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
620
626
|
return `${parts.join(", ")} into ${relativeRoot}`;
|
|
621
627
|
}
|
|
622
628
|
|
package/src/compiler/context.js
CHANGED
|
@@ -81,6 +81,11 @@
|
|
|
81
81
|
* read threads through — the real filesystem by default, or an in-memory map
|
|
82
82
|
* for `compileFromMemory`. Set once by `createCompilation` and reached via
|
|
83
83
|
* `ctx.comp.reader` in the directive handlers (includes, loop data).
|
|
84
|
+
* @property {Map<string, number[]> | null} imageVariants Build-time-GENERATED
|
|
85
|
+
* responsive variants (2.9.0): the original image's absolute source path →
|
|
86
|
+
* the sorted `@<width>` sizes the builder's pre-pass resized into dist. Null
|
|
87
|
+
* outside a builder-driven compile (e.g. `compileFromMemory`), where the
|
|
88
|
+
* srcset wiring probes author-provided variants only.
|
|
84
89
|
* @property {Symbol[]} symbols Every declared thing this compile saw, in source
|
|
85
90
|
* order, recorded by {@link recordSymbol} from the handler that parsed it.
|
|
86
91
|
* Surfaced on {@link CompiledPage} so a tool layer can answer "what is in this
|
|
@@ -290,6 +295,7 @@ export const LOOP_META = Object.assign(Object.create(null), {
|
|
|
290
295
|
export function createCompilation(reader) {
|
|
291
296
|
return {
|
|
292
297
|
reader,
|
|
298
|
+
imageVariants: null,
|
|
293
299
|
assets: {
|
|
294
300
|
skins: new Set(),
|
|
295
301
|
scripts: new Set(),
|
package/src/compiler/markdown.js
CHANGED
|
@@ -363,25 +363,36 @@ function attrBindPlugin(mdInstance) {
|
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
// ---------------------------------------------------------------------------
|
|
366
|
-
// RESPONSIVE IMAGE VARIANTS (the `@width` convention, 2.8.0 / A2
|
|
366
|
+
// RESPONSIVE IMAGE VARIANTS (the `@width` convention, 2.8.0 / A2; generated
|
|
367
|
+
// variants, 2.9.0).
|
|
367
368
|
//
|
|
368
|
-
//
|
|
369
|
-
// convention wires variants the AUTHOR (or their CDN tooling) already made:
|
|
369
|
+
// Variants come from two places, and this wiring treats them identically:
|
|
370
370
|
// next to `/doctors/dr-x.webp` sit `dr-x@640.webp`, `dr-x@1280.webp` — same
|
|
371
371
|
// directory, same extension, `@<width>` before it. When at least one exists,
|
|
372
372
|
// the markdown image gains a `srcset` (variants ascending, then the original
|
|
373
373
|
// at its intrinsic width) so the browser downloads the size it needs instead
|
|
374
374
|
// of the full-resolution file. The original stays the `src` fallback.
|
|
375
375
|
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
376
|
+
// * AUTHOR-PROVIDED (2.8.0): files the author (or their CDN tooling) placed
|
|
377
|
+
// beside the source. Discovery is an existence PROBE over
|
|
378
|
+
// {@link VARIANT_WIDTHS} through the injected reader: the Reader contract
|
|
379
|
+
// has no directory listing, so an off-list width (say `@500`) is simply not
|
|
380
|
+
// wired — a deliberate, documented miss, never an error. Each variant's
|
|
381
|
+
// descriptor width is its REAL intrinsic width from the image header (a
|
|
382
|
+
// `@640` file that actually holds 600px says 600w); when the bytes cannot
|
|
383
|
+
// be read or parsed (the in-memory reader has no binaries), the filename
|
|
384
|
+
// width is the fallback.
|
|
385
|
+
// * BUILD-TIME GENERATED (2.9.0): the builder's pre-pass (src/image-variants.js)
|
|
386
|
+
// resized the source with the optional sharp dependency and wrote the
|
|
387
|
+
// variants into dist. It hands this compile a map (original's absolute
|
|
388
|
+
// source path → widths) via `comp.imageVariants`; the widths ARE the
|
|
389
|
+
// descriptor widths because the generator made them so. An author-provided
|
|
390
|
+
// file at the same width always wins — the generator skips those widths,
|
|
391
|
+
// and the href dedupe below keeps it that way even against a stale map.
|
|
392
|
+
//
|
|
393
|
+
// The original's intrinsic width follows the same measure-with-fallback rule,
|
|
394
|
+
// except it has no filename fallback: unreadable means it is left out of the
|
|
395
|
+
// srcset and stays the plain `src` default.
|
|
385
396
|
//
|
|
386
397
|
// A bound src (``) is repainted by the runtime, so a srcset
|
|
387
398
|
// computed from the seed would go stale on the first render — those images are
|
|
@@ -483,6 +494,18 @@ function buildSrcset(src, ctx) {
|
|
|
483
494
|
if (!reader.exists(abs)) continue;
|
|
484
495
|
candidates.push({ href: `${base}@${width}${ext}`, width: measureWidth(abs, reader) ?? width });
|
|
485
496
|
}
|
|
497
|
+
// Builder-generated variants (2.9.0): resized into dist by the pre-pass, so
|
|
498
|
+
// the reader cannot probe them — they arrive through the compile instead.
|
|
499
|
+
// An author-provided sibling at the same width wins (the generator skips
|
|
500
|
+
// those widths; the href check keeps precedence even against a stale map).
|
|
501
|
+
const generated = ctx.comp.imageVariants?.get(path.join(root, `${relBase}${ext}`));
|
|
502
|
+
if (generated) {
|
|
503
|
+
for (const width of generated) {
|
|
504
|
+
const href = `${base}@${width}${ext}`;
|
|
505
|
+
if (candidates.some((c) => c.href === href)) continue;
|
|
506
|
+
candidates.push({ href, width });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
486
509
|
if (!candidates.length) return null;
|
|
487
510
|
const intrinsic = measureWidth(path.join(root, `${relBase}${ext}`), reader);
|
|
488
511
|
if (intrinsic) candidates.push({ href: src, width: intrinsic });
|
package/src/compiler/page.js
CHANGED
|
@@ -45,7 +45,7 @@ const SKIP_LINK_STYLE =
|
|
|
45
45
|
* Compile a page source file into a full HTML document plus its assets.
|
|
46
46
|
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
47
47
|
* @param {Paths} context Resolved project paths.
|
|
48
|
-
* @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]
|
|
48
|
+
* @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]
|
|
49
49
|
* When a site-wide RSS feed is emitted (the home page set `site_url` and the
|
|
50
50
|
* site has dated posts), `feed` carries its absolute href + title so every page
|
|
51
51
|
* links it. `site` carries the origin + this page's own route (and its resolved
|
|
@@ -53,14 +53,23 @@ const SKIP_LINK_STYLE =
|
|
|
53
53
|
* build-time collection index a bare-name `@loop` resolves against; `vars` seeds
|
|
54
54
|
* the document scope (the pager `page` object on a paginated route). `reader` is
|
|
55
55
|
* the source reader (fs-backed by default via `src/compiler.js`; in-memory for
|
|
56
|
-
* `compileFromMemory`).
|
|
56
|
+
* `compileFromMemory`). `imageVariants` is the builder's generated-variant map
|
|
57
|
+
* (2.9.0): original absolute source path → `@<width>` sizes resized into dist.
|
|
57
58
|
* @returns {CompiledPage}
|
|
58
59
|
*/
|
|
59
60
|
export function compilePage(file, context, options = {}) {
|
|
60
61
|
// The raw entry point requires a reader — the Node barrel (`src/compiler.js`)
|
|
61
62
|
// injects `fsReader()`, and `compileFromMemory` injects a `memoryReader`.
|
|
62
63
|
const reader = /** @type {import("./reader.js").Reader} */ (options.reader);
|
|
63
|
-
const compiled = compileDocument(
|
|
64
|
+
const compiled = compileDocument(
|
|
65
|
+
file,
|
|
66
|
+
context,
|
|
67
|
+
[],
|
|
68
|
+
options.vars,
|
|
69
|
+
options.collections,
|
|
70
|
+
reader,
|
|
71
|
+
options.imageVariants ?? null
|
|
72
|
+
);
|
|
64
73
|
const title = String(compiled.meta.title || "Darkmown");
|
|
65
74
|
const description = String(compiled.meta.description || "");
|
|
66
75
|
// Per-page document language for the `<html lang>` attribute (`lang: fr` in
|
|
@@ -434,6 +443,10 @@ function measureImage(src, paths, reader) {
|
|
|
434
443
|
* Build-time collection index a bare-name `@loop` resolves against.
|
|
435
444
|
* @param {import("./reader.js").Reader} [reader] Source reader (fs-backed when
|
|
436
445
|
* omitted via `src/compiler.js`; in-memory for `compileFromMemory`).
|
|
446
|
+
* @param {Map<string, number[]> | null} [imageVariants] Build-time-generated
|
|
447
|
+
* responsive variants (original's absolute source path → widths), threaded by
|
|
448
|
+
* the builder so the srcset wiring includes them alongside author-provided
|
|
449
|
+
* `@<width>` siblings.
|
|
437
450
|
* @returns {CompiledDocument}
|
|
438
451
|
*/
|
|
439
452
|
export function compileDocument(
|
|
@@ -442,13 +455,15 @@ export function compileDocument(
|
|
|
442
455
|
stack = [],
|
|
443
456
|
vars = {},
|
|
444
457
|
collections = new Map(),
|
|
445
|
-
reader
|
|
458
|
+
reader,
|
|
459
|
+
imageVariants = null
|
|
446
460
|
) {
|
|
447
461
|
// reader is injected by every caller (the Node barrel's `fsReader()` or
|
|
448
462
|
// `compileFromMemory`'s `memoryReader`); it is only trailing-optional here so
|
|
449
463
|
// the defaulted `vars`/`collections` params can precede it.
|
|
450
464
|
const comp = createCompilation(/** @type {import("./reader.js").Reader} */ (reader));
|
|
451
465
|
comp.collections = collections;
|
|
466
|
+
comp.imageVariants = imageVariants;
|
|
452
467
|
const result = compileFile(
|
|
453
468
|
file,
|
|
454
469
|
context,
|
package/src/compiler.js
CHANGED
|
@@ -43,7 +43,7 @@ export {
|
|
|
43
43
|
* a different source (see `compileFromMemory`).
|
|
44
44
|
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
45
45
|
* @param {import("./config.js").Paths} context Resolved project paths.
|
|
46
|
-
* @param {{ feed?: { href: string, title: string }, site?: import("./compiler/page.js").SiteContext, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader }} [options]
|
|
46
|
+
* @param {{ feed?: { href: string, title: string }, site?: import("./compiler/page.js").SiteContext, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
|
|
47
47
|
* @returns {CompiledPage}
|
|
48
48
|
*/
|
|
49
49
|
export function compilePage(file, context, options = {}) {
|
|
@@ -59,10 +59,20 @@ export function compilePage(file, context, options = {}) {
|
|
|
59
59
|
* @param {Record<string, unknown>} [vars] Initial static scope variables.
|
|
60
60
|
* @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} [collections]
|
|
61
61
|
* @param {import("./compiler/reader.js").Reader} [reader] Source reader.
|
|
62
|
+
* @param {Map<string, number[]> | null} [imageVariants] Builder-generated
|
|
63
|
+
* responsive variants (original's absolute source path → widths).
|
|
62
64
|
* @returns {CompiledDocument}
|
|
63
65
|
*/
|
|
64
|
-
export function compileDocument(
|
|
65
|
-
|
|
66
|
+
export function compileDocument(
|
|
67
|
+
file,
|
|
68
|
+
context,
|
|
69
|
+
stack,
|
|
70
|
+
vars,
|
|
71
|
+
collections,
|
|
72
|
+
reader = fsReader(),
|
|
73
|
+
imageVariants = null
|
|
74
|
+
) {
|
|
75
|
+
return compileDocumentImpl(file, context, stack, vars, collections, reader, imageVariants);
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
/**
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The resize worker for the build-time image pre-pass (2.9.0). Never imported
|
|
3
|
+
// by the builder: the build is synchronous and sharp is not, so
|
|
4
|
+
// src/image-variants.js spawns this file as a child process with a jobs JSON
|
|
5
|
+
// file and reads back a results JSON file.
|
|
6
|
+
//
|
|
7
|
+
// sharp is an OPTIONAL dependency, so it is dynamic-imported inside a
|
|
8
|
+
// try/catch: when it cannot load (not installed, broken native binding), the
|
|
9
|
+
// worker reports `{ unavailable: true }` and exits cleanly — the parent turns
|
|
10
|
+
// that into one hint, and the build ships exactly what 2.8.0 would have.
|
|
11
|
+
// A failure on ONE image is recorded in `failed` and the rest still resize.
|
|
12
|
+
// Nothing here ever exits non-zero over an image.
|
|
13
|
+
//
|
|
14
|
+
// `WD_SHARP_MODULE` overrides the sharp specifier; tests point it at a
|
|
15
|
+
// nonexistent module to exercise the sharp-absent path without uninstalling
|
|
16
|
+
// anything. It is a test seam, not a public knob.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run every job in the jobs file and write the results file.
|
|
25
|
+
* @param {string} jobsFile JSON array of `{ source, out, width, format }`.
|
|
26
|
+
* @param {string} resultsFile Written with `{ unavailable, failed }`.
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
export async function runJobsFile(jobsFile, resultsFile) {
|
|
30
|
+
const jobs = JSON.parse(fs.readFileSync(jobsFile, "utf8"));
|
|
31
|
+
/** @type {(result: { unavailable: boolean, failed: { file: string, width: number, error: string }[] }) => void} */
|
|
32
|
+
const write = (result) => fs.writeFileSync(resultsFile, JSON.stringify(result));
|
|
33
|
+
/** @type {any} */
|
|
34
|
+
let sharp;
|
|
35
|
+
try {
|
|
36
|
+
sharp = (await import(process.env.WD_SHARP_MODULE || "sharp")).default;
|
|
37
|
+
} catch {
|
|
38
|
+
write({ unavailable: true, failed: [] });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
/** @type {{ file: string, width: number, error: string }[]} */
|
|
42
|
+
const failed = [];
|
|
43
|
+
for (const job of jobs) {
|
|
44
|
+
try {
|
|
45
|
+
// `rotate()` honors EXIF orientation before resizing: the emitted variant
|
|
46
|
+
// loses the EXIF tag with its pixels physically rotated, so it displays
|
|
47
|
+
// exactly like the tag-carrying original. `withoutEnlargement` is belt and
|
|
48
|
+
// braces — the pre-pass only ever queues widths below the intrinsic size.
|
|
49
|
+
let pipeline = sharp(job.source)
|
|
50
|
+
.rotate()
|
|
51
|
+
.resize({ width: job.width, withoutEnlargement: true });
|
|
52
|
+
if (job.format === "webp") pipeline = pipeline.webp({ quality: 82 });
|
|
53
|
+
else if (job.format === "jpeg") pipeline = pipeline.jpeg({ quality: 82, progressive: true });
|
|
54
|
+
else pipeline = pipeline.png({ compressionLevel: 9 });
|
|
55
|
+
fs.mkdirSync(path.dirname(job.out), { recursive: true });
|
|
56
|
+
await pipeline.toFile(job.out);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
failed.push({
|
|
59
|
+
file: job.source,
|
|
60
|
+
width: job.width,
|
|
61
|
+
error: err instanceof Error ? err.message : String(err)
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
write({ unavailable: false, failed });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Entry-point guard, one line so importing this module in a test registers the
|
|
69
|
+
// line as executed without running main (same pattern as the coverage gate).
|
|
70
|
+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url)
|
|
71
|
+
runJobsFile(process.argv[2], process.argv[3]);
|
|
@@ -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/types/builder.d.ts
CHANGED
|
@@ -98,6 +98,15 @@ export type BuildResult = {
|
|
|
98
98
|
sitemap: number | null;
|
|
99
99
|
rss: number | null;
|
|
100
100
|
};
|
|
101
|
+
/**
|
|
102
|
+
* Responsive-variant pre-pass counts (2.9.0): freshly resized, cache-served,
|
|
103
|
+
* and whether sharp could not be loaded at all.
|
|
104
|
+
*/
|
|
105
|
+
images: {
|
|
106
|
+
generated: number;
|
|
107
|
+
cached: number;
|
|
108
|
+
unavailable: boolean;
|
|
109
|
+
};
|
|
101
110
|
incremental?: {
|
|
102
111
|
changed: string[];
|
|
103
112
|
rebuilt: string[];
|
|
@@ -66,6 +66,11 @@
|
|
|
66
66
|
* read threads through — the real filesystem by default, or an in-memory map
|
|
67
67
|
* for `compileFromMemory`. Set once by `createCompilation` and reached via
|
|
68
68
|
* `ctx.comp.reader` in the directive handlers (includes, loop data).
|
|
69
|
+
* @property {Map<string, number[]> | null} imageVariants Build-time-GENERATED
|
|
70
|
+
* responsive variants (2.9.0): the original image's absolute source path →
|
|
71
|
+
* the sorted `@<width>` sizes the builder's pre-pass resized into dist. Null
|
|
72
|
+
* outside a builder-driven compile (e.g. `compileFromMemory`), where the
|
|
73
|
+
* srcset wiring probes author-provided variants only.
|
|
69
74
|
* @property {Symbol[]} symbols Every declared thing this compile saw, in source
|
|
70
75
|
* order, recorded by {@link recordSymbol} from the handler that parsed it.
|
|
71
76
|
* Surfaced on {@link CompiledPage} so a tool layer can answer "what is in this
|
|
@@ -488,6 +493,14 @@ export type Compilation = {
|
|
|
488
493
|
* `ctx.comp.reader` in the directive handlers (includes, loop data).
|
|
489
494
|
*/
|
|
490
495
|
reader: import("./reader.js").Reader;
|
|
496
|
+
/**
|
|
497
|
+
* Build-time-GENERATED
|
|
498
|
+
* responsive variants (2.9.0): the original image's absolute source path →
|
|
499
|
+
* the sorted `@<width>` sizes the builder's pre-pass resized into dist. Null
|
|
500
|
+
* outside a builder-driven compile (e.g. `compileFromMemory`), where the
|
|
501
|
+
* srcset wiring probes author-provided variants only.
|
|
502
|
+
*/
|
|
503
|
+
imageVariants: Map<string, number[]> | null;
|
|
491
504
|
/**
|
|
492
505
|
* Every declared thing this compile saw, in source
|
|
493
506
|
* order, recorded by {@link recordSymbol} from the handler that parsed it.
|
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
|
package/types/compiler.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* a different source (see `compileFromMemory`).
|
|
5
5
|
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
6
6
|
* @param {import("./config.js").Paths} context Resolved project paths.
|
|
7
|
-
* @param {{ feed?: { href: string, title: string }, site?: import("./compiler/page.js").SiteContext, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader }} [options]
|
|
7
|
+
* @param {{ feed?: { href: string, title: string }, site?: import("./compiler/page.js").SiteContext, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
|
|
8
8
|
* @returns {CompiledPage}
|
|
9
9
|
*/
|
|
10
10
|
export function compilePage(file: string, context: import("./config.js").Paths, options?: {
|
|
@@ -16,6 +16,7 @@ export function compilePage(file: string, context: import("./config.js").Paths,
|
|
|
16
16
|
collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>;
|
|
17
17
|
vars?: Record<string, unknown>;
|
|
18
18
|
reader?: import("./compiler/reader.js").Reader;
|
|
19
|
+
imageVariants?: Map<string, number[]> | null;
|
|
19
20
|
}): CompiledPage;
|
|
20
21
|
/**
|
|
21
22
|
* Compile a source file into its body HTML, frontmatter, and assets (no page
|
|
@@ -26,9 +27,11 @@ export function compilePage(file: string, context: import("./config.js").Paths,
|
|
|
26
27
|
* @param {Record<string, unknown>} [vars] Initial static scope variables.
|
|
27
28
|
* @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} [collections]
|
|
28
29
|
* @param {import("./compiler/reader.js").Reader} [reader] Source reader.
|
|
30
|
+
* @param {Map<string, number[]> | null} [imageVariants] Builder-generated
|
|
31
|
+
* responsive variants (original's absolute source path → widths).
|
|
29
32
|
* @returns {CompiledDocument}
|
|
30
33
|
*/
|
|
31
|
-
export function compileDocument(file: string, context: import("./config.js").Paths, stack?: string[], vars?: Record<string, unknown>, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, reader?: import("./compiler/reader.js").Reader): CompiledDocument;
|
|
34
|
+
export function compileDocument(file: string, context: import("./config.js").Paths, stack?: string[], vars?: Record<string, unknown>, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, reader?: import("./compiler/reader.js").Reader, imageVariants?: Map<string, number[]> | null): CompiledDocument;
|
|
32
35
|
/**
|
|
33
36
|
* Harden every `<img>` in an assembled page body, reading intrinsic dimensions
|
|
34
37
|
* from the real filesystem by default.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan `site/pages` for raster images and generate `@<width>` variants into
|
|
3
|
+
* `dist`, returning the variant map the compiler wires into srcsets.
|
|
4
|
+
*
|
|
5
|
+
* Skips, none of them errors: `@<width>` variant files themselves (they are
|
|
6
|
+
* output, not sources), hidden-path and symlinked files (mirroring
|
|
7
|
+
* `emitPageAssets`, which would never publish them), images at or under 700px
|
|
8
|
+
* wide (already small), widths at or above the intrinsic size (never
|
|
9
|
+
* upscale), and widths the author already provided (their file wins). An image
|
|
10
|
+
* whose dimensions cannot be read, and one sharp fails on, get a hint naming
|
|
11
|
+
* the file and ship without variants.
|
|
12
|
+
* @param {Paths} paths
|
|
13
|
+
* @param {{ widths?: number[], cacheDir?: string, quiet?: boolean,
|
|
14
|
+
* runJobs?: (jobs: ResizeJob[]) => ResizeResult }} [options]
|
|
15
|
+
* `widths` overrides {@link DEFAULT_VARIANT_WIDTHS}. `quiet` suppresses the
|
|
16
|
+
* hints (dev-server rebuilds, which already printed them at the initial
|
|
17
|
+
* build). `runJobs` is the resize seam — the default spawns the worker
|
|
18
|
+
* process; tests inject fakes for the unavailable/crash paths.
|
|
19
|
+
* @returns {ImageVariantPlan}
|
|
20
|
+
*/
|
|
21
|
+
export function generateImageVariants(paths: Paths, options?: {
|
|
22
|
+
widths?: number[];
|
|
23
|
+
cacheDir?: string;
|
|
24
|
+
quiet?: boolean;
|
|
25
|
+
runJobs?: (jobs: ResizeJob[]) => ResizeResult;
|
|
26
|
+
}): ImageVariantPlan;
|
|
27
|
+
/**
|
|
28
|
+
* The default resize seam: run every pending job in ONE child process (the
|
|
29
|
+
* build is synchronous; sharp is not), communicating through temp JSON files
|
|
30
|
+
* rather than stdio so a large job list never hits a pipe buffer. The worker
|
|
31
|
+
* always writes a results file — a missing or unparsable one means the child
|
|
32
|
+
* itself died, which is reported as `unavailable` (the build then behaves as
|
|
33
|
+
* if sharp were absent: a hint, no variants, no failure).
|
|
34
|
+
* @param {ResizeJob[]} jobs
|
|
35
|
+
* @param {(cmd: string, args: string[], opts: object) => unknown} [spawn]
|
|
36
|
+
* Injectable for tests; the real one is `spawnSync`.
|
|
37
|
+
* @returns {ResizeResult}
|
|
38
|
+
*/
|
|
39
|
+
export function runResizeJobs(jobs: ResizeJob[], spawn?: (cmd: string, args: string[], opts: object) => unknown): ResizeResult;
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {import("./config.js").Paths} Paths
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* One resize handed to the worker: shrink `source` to `width` pixels wide and
|
|
45
|
+
* write it (in its own format) to `out`, the cache path.
|
|
46
|
+
* @typedef {object} ResizeJob
|
|
47
|
+
* @property {string} source Absolute path of the original image.
|
|
48
|
+
* @property {string} out Absolute cache path the resized variant is written to.
|
|
49
|
+
* @property {number} width Target width in pixels (always below the intrinsic).
|
|
50
|
+
* @property {"jpeg" | "png" | "webp"} format Output encoder, from the source extension.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* What the worker reports back. `unavailable` means sharp could not be loaded
|
|
54
|
+
* at all (not installed, or a broken native binding) — every pending image
|
|
55
|
+
* then ships without variants. `failed` carries one entry per image that
|
|
56
|
+
* sharp itself rejected (corrupt payload, unsupported encoding).
|
|
57
|
+
* @typedef {object} ResizeResult
|
|
58
|
+
* @property {boolean} unavailable
|
|
59
|
+
* @property {{ file: string, width: number, error: string }[]} failed
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* The pre-pass result: `variants` is the map the compiler's srcset wiring
|
|
63
|
+
* consults (original's ABSOLUTE source path → sorted widths generated into
|
|
64
|
+
* dist), and the counts feed the build summary. `unavailable` marks the
|
|
65
|
+
* sharp-absent build (which is otherwise identical to not having the feature).
|
|
66
|
+
* @typedef {object} ImageVariantPlan
|
|
67
|
+
* @property {Map<string, number[]>} variants
|
|
68
|
+
* @property {number} generated Variants resized this build.
|
|
69
|
+
* @property {number} cached Variants served from the on-disk cache.
|
|
70
|
+
* @property {boolean} unavailable sharp could not be loaded.
|
|
71
|
+
*/
|
|
72
|
+
/** The widths generated by default, both comfortably under a typical source. */
|
|
73
|
+
export const DEFAULT_VARIANT_WIDTHS: number[];
|
|
74
|
+
export type Paths = import("./config.js").Paths;
|
|
75
|
+
/**
|
|
76
|
+
* One resize handed to the worker: shrink `source` to `width` pixels wide and
|
|
77
|
+
* write it (in its own format) to `out`, the cache path.
|
|
78
|
+
*/
|
|
79
|
+
export type ResizeJob = {
|
|
80
|
+
/**
|
|
81
|
+
* Absolute path of the original image.
|
|
82
|
+
*/
|
|
83
|
+
source: string;
|
|
84
|
+
/**
|
|
85
|
+
* Absolute cache path the resized variant is written to.
|
|
86
|
+
*/
|
|
87
|
+
out: string;
|
|
88
|
+
/**
|
|
89
|
+
* Target width in pixels (always below the intrinsic).
|
|
90
|
+
*/
|
|
91
|
+
width: number;
|
|
92
|
+
/**
|
|
93
|
+
* Output encoder, from the source extension.
|
|
94
|
+
*/
|
|
95
|
+
format: "jpeg" | "png" | "webp";
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* What the worker reports back. `unavailable` means sharp could not be loaded
|
|
99
|
+
* at all (not installed, or a broken native binding) — every pending image
|
|
100
|
+
* then ships without variants. `failed` carries one entry per image that
|
|
101
|
+
* sharp itself rejected (corrupt payload, unsupported encoding).
|
|
102
|
+
*/
|
|
103
|
+
export type ResizeResult = {
|
|
104
|
+
unavailable: boolean;
|
|
105
|
+
failed: {
|
|
106
|
+
file: string;
|
|
107
|
+
width: number;
|
|
108
|
+
error: string;
|
|
109
|
+
}[];
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* The pre-pass result: `variants` is the map the compiler's srcset wiring
|
|
113
|
+
* consults (original's ABSOLUTE source path → sorted widths generated into
|
|
114
|
+
* dist), and the counts feed the build summary. `unavailable` marks the
|
|
115
|
+
* sharp-absent build (which is otherwise identical to not having the feature).
|
|
116
|
+
*/
|
|
117
|
+
export type ImageVariantPlan = {
|
|
118
|
+
variants: Map<string, number[]>;
|
|
119
|
+
/**
|
|
120
|
+
* Variants resized this build.
|
|
121
|
+
*/
|
|
122
|
+
generated: number;
|
|
123
|
+
/**
|
|
124
|
+
* Variants served from the on-disk cache.
|
|
125
|
+
*/
|
|
126
|
+
cached: number;
|
|
127
|
+
/**
|
|
128
|
+
* sharp could not be loaded.
|
|
129
|
+
*/
|
|
130
|
+
unavailable: boolean;
|
|
131
|
+
};
|