@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 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:** export pre-sized copies next to the source image, same directory, same extension, `@<width>` before it (`dr-x@640.webp`, `dr-x@1280.webp`), and a Markdown image gains a `srcset` automatically, the original kept as `src`. Widths are MEASURED from file headers (a `@640` file really holding 600px emits `600w`; the filename is only a fallback), candidates sort ascending, and discovery probes a fixed width list (64 through 3840: 64, 96, 128, 160, 256, 320, 480, 640, 768, 960, 1024, 1280, 1440, 1536, 1920, 2048, 2560, 3840), so an off-list width is a documented miss, never an error. `sizes` is never emitted (write raw `<img srcset sizes>` on an `html: true` page for that). Never wired: bound srcs (`![a]({ photo })`), remote/`data:` sources, query/hash srcs, page-relative or extensionless srcs, and raw-HTML `<img>` tags. Variant files are ordinary colocated assets (they copy to `dist/`, never routes); shelf images resolve variants against `/__wd/media/`. The first image on the page stays eager with `fetchpriority="high"`, the rest lazy-load, and author-set attributes always win. `.md` pages get the width/height/lazy hardening but no srcset.
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 (`![a]({ photo })`), 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: `![logo](/__wd/media/logo.svg)`. 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 via the `@width` convention.** Darkmown still resizes nothing, but it wires the variants you already exported. Put pre-sized copies next to the source, same directory, same extension, `@<width>` before it (`dr-x.webp` beside `dr-x@640.webp` and `dr-x@1280.webp`), and a Markdown image gains a `srcset` with the original kept as `src`:
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-x.webp" alt="Dr X"
222
- srcset="/doctors/dr-x@640.webp 600w, /doctors/dr-x@1280.webp 1280w, /doctors/dr-x.webp 2400w"
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
- - **Descriptor widths are measured, not parsed from the filename.** Each variant's intrinsic width is read from its file header (PNG/JPEG/GIF/WebP/SVG), so a file named `@640` that really holds 600px emits `600w`; the filename width is only the fallback when the bytes cannot be read. The original joins the srcset at its intrinsic width when that can be measured, and all candidates sort ascending, so an original narrower than a variant sorts between them.
227
- - **Discovery probes a fixed width list** (64, 96, 128, 160, 256, 320, 480, 640, 768, 960, 1024, 1280, 1440, 1536, 1920, 2048, 2560, 3840) through existence checks rather than a directory listing, which is what lets `compileFromMemory` resolve the convention from its in-memory map too. An off-list width like `x@500.webp` is a documented miss: never wired, never an error.
228
- - **`sizes` is never emitted.** The inline-attribute syntax on images carries only classes and ids, so there is no way to supply `sizes` in Markdown, and the compiler will not invent one. An author who needs `sizes` writes raw `<img srcset sizes>` on an `html: true` page.
229
- - **Never wired, by design:** a bound src (`![a]({ photo })` or a loop row's `![a]({ p.photo })`, which the runtime repaints and a build-time srcset would contradict), remote (`https://`, protocol-relative) and `data:` sources, srcs with a query/hash, page-relative or extensionless srcs, and raw-HTML `<img>` tags (author territory). With no variant present, the emitted tag is byte-identical to before the feature.
230
- - Variant files are ordinary colocated assets: they copy to `dist/` at their own path like any image, and an `@`-suffixed filename is never a route. Shelf images work too: variants beside `site/_/team/x.webp` resolve against the `/__wd/media/` public path.
231
- - The srcset composes with the load-priority split above: the first image stays eager with `fetchpriority="high"`, later variant-wired images get `loading="lazy"`, and author-set `loading`/`decoding`/`fetchpriority`/`width`/`height` are never overwritten.
232
- - **`.md` caveat:** variant wiring runs in the `.wd` compile path. A plain `.md` page still gets the width/height/lazy hardening but no srcset.
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 (`![a]({ photo })` or a loop row's `![a]({ p.photo })`, 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
 
@@ -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.8.0",
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
  }
@@ -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.** Export pre-sized copies next to the source, same directory, same extension, `@<width>` before it (`dr-x@640.webp`, `dr-x@1280.webp`), and a Markdown image gains a `srcset` with the original kept as `src`. Descriptor widths are measured from the file headers (a `@640` file really holding 600px emits `600w`), candidates sort ascending, and the original joins at its intrinsic width. Discovery probes a fixed list of common widths (64 through 3840) rather than reading the directory, so an off-list width is a documented miss, never an error. `sizes` is never emitted; write raw `<img srcset sizes>` on an `html: true` page when you need one. Bound (`![a]({ photo })`), remote, `data:`, query/hash, page-relative, and extensionless srcs are never wired, and neither are raw-HTML `<img>` tags. Variant files are ordinary colocated assets: they copy to `dist/` and are never routes, and shelf images resolve variants against `/__wd/media/`. A `.md` page gets the hardening but no srcset.
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 (`![a]({ photo })`), 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(route, cwd, paths, collections, feedLink, warned, identity, titles);
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 { routes: manifest, distRoot: paths.distRoot, feeds };
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(route, cwd, paths, collections, feedLink, warned, identity, titles);
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(route, cwd, paths, collections, feedLink, warned, identity, titles) {
350
- const compiled = compileRoutePages(route, paths, collections, feedLink, identity, titles);
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
 
@@ -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(),
@@ -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
- // Darkmown has no image pipeline and never will (no resize dependency), so the
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
- // Discovery is an existence PROBE over {@link VARIANT_WIDTHS} through the
377
- // injected reader: the Reader contract has no directory listing, so an off-list
378
- // width (say `@500`) is simply not wired — a deliberate, documented miss, never
379
- // an error. Each variant's descriptor width is its REAL intrinsic width from
380
- // the image header (a `@640` file that actually holds 600px says 600w); when
381
- // the bytes cannot be read or parsed (the in-memory reader has no binaries), the
382
- // filename width is the fallback. The original's intrinsic width follows the
383
- // same rule, except it has no filename fallback: unreadable means it is left
384
- // out of the srcset and stays the plain `src` default.
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 (`![alt]({ photo })`) 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 });
@@ -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(file, context, [], options.vars, options.collections, reader);
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(file, context, stack, vars, collections, reader = fsReader()) {
65
- return compileDocumentImpl(file, context, stack, vars, collections, reader);
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
+ }
@@ -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.
@@ -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
@@ -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
+ };