@sarimarcus/content-sites-core 0.20.0 → 0.21.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/CHANGELOG.md +4 -0
- package/README.md +9 -1
- package/dist/config/siteConfig.js +4 -7
- package/dist/images/cli.d.ts +2 -0
- package/dist/images/cli.js +42 -0
- package/dist/images/index.d.ts +24 -0
- package/dist/images/index.js +23 -0
- package/dist/images/service.d.ts +7 -0
- package/dist/images/service.js +85 -0
- package/dist/images/variants.d.ts +45 -0
- package/dist/images/variants.js +145 -0
- package/dist/interludes/index.d.ts +1 -1
- package/package.json +23 -2
- package/src/config/siteConfig.ts +4 -7
- package/src/images/cli.ts +39 -0
- package/src/images/index.ts +41 -0
- package/src/images/service.ts +95 -0
- package/src/images/variants.ts +179 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.21.0 (2026-10-02)
|
|
6
|
+
|
|
7
|
+
- New `./images`: one width ladder (`IMAGE_LADDER`, `ladderWidths`, `snapWidth`, `variantRungs`) and `./images/service`, an Astro image service that never encodes. It serves committed WebP variants from the site's `image-variants/` and the source bytes at the native width, so production srcsets point at real files of the width they claim; previously every width resolved to one file under the noop service. `./images/variants` and the `content-sites-image-variants` bin generate, prune and check the variants (`sharp` is an optional peer). `defineSiteConfig` installs the service on every build in place of noop/sharp (SLA-2912).
|
|
8
|
+
|
|
5
9
|
## 0.20.0 (2026-10-02)
|
|
6
10
|
|
|
7
11
|
- New `./interludes`: `createInterludeSchema(z, config)` builds the 14-member article interlude union from a site's entity taxonomy (`relatedCardTypes`, `featureTypes`) with the site's own zod; `INTERLUDE_TYPES` and `INTERLUDE_MAX_PER_SECTION` for factory gates. Replaces the per-site generated `interludes.schema.ts` (SLA-2740).
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Non-visual code shared by the content sites: generic utilities, Astro config bui
|
|
|
4
4
|
and the type contracts `ui` and `tourism` build on (for example the card item and tag-link shapes that
|
|
5
5
|
today's ui components import from tourism code).
|
|
6
6
|
|
|
7
|
-
Named entry points: `url`, `dates`, `geo`, `dom`, `text`, `config`, `types` and `
|
|
7
|
+
Named entry points: `url`, `dates`, `geo`, `dom`, `text`, `config`, `types`, `schema`, `interludes` and `images` (`src/internal` is never exported). No `.astro`
|
|
8
8
|
files. Lowest layer: imports nothing from the other packages; its one runtime dependency is `marked` (`readingTime`). Ownership per path is in
|
|
9
9
|
`.planning/platform/catalogue.json` (owner `core`).
|
|
10
10
|
|
|
@@ -23,3 +23,11 @@ Builders are factories whose options carry each site's differences, so a site re
|
|
|
23
23
|
for byte, key order included. It also holds the opening-hours model (`generateOpeningHours`, `parseHoursString`,
|
|
24
24
|
`assertedOpenDays`, `hoursStringOpenDays`) that `scripts/validate-opening-hours.mjs` imports. The factory scripts
|
|
25
25
|
load `dist/`; `scripts/lib/core-dist.mjs` rebuilds it when it is missing or older than `src/`.
|
|
26
|
+
|
|
27
|
+
`images` holds the one responsive width ladder (`IMAGE_LADDER`: 320, 480, 640, 960, 1280; WebP only) and the image
|
|
28
|
+
service `defineSiteConfig` installs. The service never encodes: it serves committed variants from the site's
|
|
29
|
+
`image-variants/` (keyed by the source's content hash, listed in `manifest.json`) and the source bytes for the native
|
|
30
|
+
width, and caps each srcset at the source width. A call site asks for a responsive image with
|
|
31
|
+
`widths={IMAGE_LADDER}` plus `sizes`; images without variants (galleries) get a single `src` and no srcset.
|
|
32
|
+
`content-sites-image-variants` (bin, `npm run images:variants` in a site) writes missing variants with sharp, an
|
|
33
|
+
optional peer, and prunes unused ones; `--check` is the local form of the push gate `scripts/validate-image-variants.mjs`.
|
|
@@ -6,6 +6,7 @@ import sitemap, {} from '@astrojs/sitemap';
|
|
|
6
6
|
import { outboundLinkRel } from "./outboundLinkRel.js";
|
|
7
7
|
import { guideDateMap } from "./guideDates.js";
|
|
8
8
|
import { EPOCH } from "../dates/lastmod.js";
|
|
9
|
+
import { VARIANTS_DIR } from "../images/index.js";
|
|
9
10
|
/** What Pagefind leaves out of the index on every site: site chrome, breadcrumbs and anything marked ignore. */
|
|
10
11
|
export const PAGEFIND_EXCLUDE_SELECTORS = ['nav', 'footer', '.breadcrumb', '[data-pagefind-ignore]'];
|
|
11
12
|
// A date-only value is stamped at noon UTC, except today's at midnight: never in the future, and the same on every
|
|
@@ -116,14 +117,10 @@ export function defineSiteConfig(opts) {
|
|
|
116
117
|
trailingSlash: 'never',
|
|
117
118
|
compressHTML: true,
|
|
118
119
|
image: {
|
|
120
|
+
// Never encodes: serves the committed variants in image-variants/ (npm run images:variants writes them).
|
|
119
121
|
service: {
|
|
120
|
-
entrypoint:
|
|
121
|
-
config: {
|
|
122
|
-
limitInputPixels: false,
|
|
123
|
-
avif: { quality: 65, effort: 6, chromaSubsampling: '4:2:0' },
|
|
124
|
-
// Astro re-encodes from decoded pixels, so the delivered quality is set here, not by the source file.
|
|
125
|
-
webp: { quality: 65, effort: 6 },
|
|
126
|
-
},
|
|
122
|
+
entrypoint: '@sarimarcus/content-sites-core/images/service',
|
|
123
|
+
config: { variantsDir: path.join(rootDir, VARIANTS_DIR) },
|
|
127
124
|
},
|
|
128
125
|
},
|
|
129
126
|
build: {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// content-sites-image-variants [--check] [--root=<site dir>]
|
|
3
|
+
// (default) write missing variants, prune unused ones, rewrite the manifest
|
|
4
|
+
// --check the pre-push gate: exit 1 when any eligible source lacks a variant
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { VARIANTS_DIR } from "./index.js";
|
|
7
|
+
import { checkVariants, generateVariants, workingTree } from "./variants.js";
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const unknown = args.filter((a) => a !== '--check' && !a.startsWith('--root='));
|
|
10
|
+
if (unknown.length) {
|
|
11
|
+
console.error(`unknown argument(s): ${unknown.join(' ')}\nUsage: content-sites-image-variants [--check] [--root=<site dir>]`);
|
|
12
|
+
process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
const root = resolve(args.find((a) => a.startsWith('--root='))?.slice('--root='.length) ?? process.cwd());
|
|
15
|
+
try {
|
|
16
|
+
if (args.includes('--check')) {
|
|
17
|
+
const r = checkVariants(workingTree(root));
|
|
18
|
+
if (!r.sources) {
|
|
19
|
+
console.error(`✗ no eligible images under ${root}/src/assets — nothing examined, not a pass`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
if (r.orphans.length)
|
|
23
|
+
console.warn(`⚠ ${r.orphans.length} unused variant file(s) in ${VARIANTS_DIR}/ — npm run images:variants prunes them`);
|
|
24
|
+
if (r.problems.length) {
|
|
25
|
+
for (const p of r.problems.slice(0, 50))
|
|
26
|
+
console.error(` ${p}`);
|
|
27
|
+
if (r.problems.length > 50)
|
|
28
|
+
console.error(` … and ${r.problems.length - 50} more`);
|
|
29
|
+
console.error(`✗ ${r.problems.length} image variant problem(s) across ${r.sources} source(s) — run \`npm run images:variants\` and commit ${VARIANTS_DIR}/`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
console.log(`✓ ${r.sources} source image(s) checked, ${r.variants} committed variant(s) present`);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const r = await generateVariants(root, (line) => console.log(line));
|
|
36
|
+
console.log(`✓ ${r.sources} source image(s): ${r.written} variant(s) written, ${r.kept} already present, ${r.pruned} pruned`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
console.error(`✗ ${err.message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The one width ladder every responsive image uses. WebP only. Not frozen: Astro sorts `widths` in place. */
|
|
2
|
+
export declare const IMAGE_LADDER: number[];
|
|
3
|
+
/** Where a site keeps its committed variants, relative to the site root. */
|
|
4
|
+
export declare const VARIANTS_DIR = "image-variants";
|
|
5
|
+
export declare const MANIFEST_FILE = "manifest.json";
|
|
6
|
+
/** Directories under src/assets whose images get no variants (galleries). */
|
|
7
|
+
export declare const EXCLUDED_DIR: RegExp;
|
|
8
|
+
export interface VariantEntry {
|
|
9
|
+
w: number;
|
|
10
|
+
h: number;
|
|
11
|
+
fmt: string;
|
|
12
|
+
/** Widths with a committed file. */
|
|
13
|
+
rungs: number[];
|
|
14
|
+
/** Paths relative to src/assets that share these bytes. */
|
|
15
|
+
src: string[];
|
|
16
|
+
}
|
|
17
|
+
export type VariantManifest = Record<string, VariantEntry>;
|
|
18
|
+
/** The srcset widths for an image `native` px wide: the rungs below it, then the native width. */
|
|
19
|
+
export declare function ladderWidths(native: number): number[];
|
|
20
|
+
/** The width actually delivered for a requested width: the smallest srcset entry at least that wide. */
|
|
21
|
+
export declare function snapWidth(requested: number, native: number): number;
|
|
22
|
+
/** The widths that need a committed file; the native entry is served from the source bytes when it is WebP. */
|
|
23
|
+
export declare function variantRungs(native: number, format: string): number[];
|
|
24
|
+
export declare function variantFileName(sha: string, width: number): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The one width ladder every responsive image uses. WebP only. Not frozen: Astro sorts `widths` in place. */
|
|
2
|
+
export const IMAGE_LADDER = [320, 480, 640, 960, 1280];
|
|
3
|
+
/** Where a site keeps its committed variants, relative to the site root. */
|
|
4
|
+
export const VARIANTS_DIR = 'image-variants';
|
|
5
|
+
export const MANIFEST_FILE = 'manifest.json';
|
|
6
|
+
/** Directories under src/assets whose images get no variants (galleries). */
|
|
7
|
+
export const EXCLUDED_DIR = /(^|\/)([^/]*-)?gallery(\/|$)/;
|
|
8
|
+
/** The srcset widths for an image `native` px wide: the rungs below it, then the native width. */
|
|
9
|
+
export function ladderWidths(native) {
|
|
10
|
+
return [...IMAGE_LADDER.filter((w) => w < native), native];
|
|
11
|
+
}
|
|
12
|
+
/** The width actually delivered for a requested width: the smallest srcset entry at least that wide. */
|
|
13
|
+
export function snapWidth(requested, native) {
|
|
14
|
+
return ladderWidths(native).find((w) => w >= requested) ?? native;
|
|
15
|
+
}
|
|
16
|
+
/** The widths that need a committed file; the native entry is served from the source bytes when it is WebP. */
|
|
17
|
+
export function variantRungs(native, format) {
|
|
18
|
+
const widths = ladderWidths(native);
|
|
19
|
+
return format === 'webp' ? widths.slice(0, -1) : widths;
|
|
20
|
+
}
|
|
21
|
+
export function variantFileName(sha, width) {
|
|
22
|
+
return `${sha}-${width}.webp`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Astro image service for production builds: never encodes. It returns the committed variants the
|
|
2
|
+
// generator wrote (variants.ts) and the source bytes for the native width, so CI stays as fast as noop
|
|
3
|
+
// while srcset entries point at genuinely different files.
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
import { baseService } from 'astro/assets';
|
|
7
|
+
import { MANIFEST_FILE, ladderWidths, snapWidth, variantFileName } from "./index.js";
|
|
8
|
+
import { parseManifest, sourceSha } from "./variants.js";
|
|
9
|
+
const manifests = new Map();
|
|
10
|
+
function manifestFor(dir) {
|
|
11
|
+
let manifest = manifests.get(dir);
|
|
12
|
+
if (!manifest) {
|
|
13
|
+
const file = join(dir, MANIFEST_FILE);
|
|
14
|
+
manifest = parseManifest(existsSync(file) ? readFileSync(file, 'utf-8') : null, file);
|
|
15
|
+
manifests.set(dir, manifest);
|
|
16
|
+
}
|
|
17
|
+
return manifest;
|
|
18
|
+
}
|
|
19
|
+
// getImage hands getSrcSet a clone of the import without its fsPath, so coverage is matched on file stem + size.
|
|
20
|
+
const coverKey = (stem, w, h) => `${stem}|${w}x${h}`;
|
|
21
|
+
const covered = new Map();
|
|
22
|
+
function isCovered(dir, src) {
|
|
23
|
+
let set = covered.get(dir);
|
|
24
|
+
if (!set) {
|
|
25
|
+
set = new Set(Object.values(manifestFor(dir)).flatMap((e) => e.src.map((rel) => coverKey(basename(rel).replace(/\.[^.]+$/, ''), e.w, e.h))));
|
|
26
|
+
covered.set(dir, set);
|
|
27
|
+
}
|
|
28
|
+
// Built: /_astro/<stem>.<hash>.webp; dev: /@fs/…/<stem>.webp?origWidth=…
|
|
29
|
+
const file = basename(src.src.split('?')[0]).replace(/\.[^.]+$/, '');
|
|
30
|
+
return set.has(coverKey(file, src.width, src.height)) || set.has(coverKey(file.replace(/\.[^.]+$/, ''), src.width, src.height));
|
|
31
|
+
}
|
|
32
|
+
const isImported = (src) => typeof src === 'object' && src !== null;
|
|
33
|
+
const service = {
|
|
34
|
+
...baseService,
|
|
35
|
+
propertiesToHash: ['src', 'width', 'format'],
|
|
36
|
+
async validateOptions(options, imageConfig, logger) {
|
|
37
|
+
if (isImported(options.src) && options.src.format === 'svg') {
|
|
38
|
+
options.format = 'svg';
|
|
39
|
+
return options;
|
|
40
|
+
}
|
|
41
|
+
if (options.format && options.format !== 'webp') {
|
|
42
|
+
throw new Error(`Image format "${options.format}" is not served: the variant service delivers WebP only (src: ${isImported(options.src) ? options.src.src : options.src}).`);
|
|
43
|
+
}
|
|
44
|
+
options.format = 'webp';
|
|
45
|
+
return baseService.validateOptions(options, imageConfig, logger);
|
|
46
|
+
},
|
|
47
|
+
// Passing `widths` (always IMAGE_LADDER) asks for a responsive image; the entries come from the ladder, capped at the source.
|
|
48
|
+
// An image with no variants (a gallery) gets no srcset rather than one that repeats a single file.
|
|
49
|
+
getSrcSet(options, imageConfig) {
|
|
50
|
+
if (!options.widths?.length || !isImported(options.src))
|
|
51
|
+
return [];
|
|
52
|
+
if (!isCovered(imageConfig.service.config.variantsDir, options.src))
|
|
53
|
+
return [];
|
|
54
|
+
const { width: native, height } = options.src;
|
|
55
|
+
const { width: _w, height: _h, widths: _ws, densities: _d, ...rest } = options;
|
|
56
|
+
return ladderWidths(native).map((width) => ({
|
|
57
|
+
transform: { ...rest, width, height: Math.round((width * height) / native) },
|
|
58
|
+
descriptor: `${width}w`,
|
|
59
|
+
attributes: { type: 'image/webp' },
|
|
60
|
+
}));
|
|
61
|
+
},
|
|
62
|
+
async transform(inputBuffer, transform, imageConfig, logger) {
|
|
63
|
+
const format = transform.format;
|
|
64
|
+
if (format === 'svg')
|
|
65
|
+
return { data: inputBuffer, format };
|
|
66
|
+
const dir = imageConfig.service.config.variantsDir;
|
|
67
|
+
const sha = sourceSha(inputBuffer);
|
|
68
|
+
const entry = manifestFor(dir)[sha];
|
|
69
|
+
if (!entry) {
|
|
70
|
+
// A gallery (excluded by design), or an image nobody ran images:variants for; the push gate refuses the latter.
|
|
71
|
+
logger.warn(`no variants for ${transform.src}; serving the source bytes`);
|
|
72
|
+
return { data: inputBuffer, format };
|
|
73
|
+
}
|
|
74
|
+
// An off-ladder width (an avatar's width={64}) keeps its HTML attributes and gets the next file up.
|
|
75
|
+
const width = snapWidth(Number(transform.width) || entry.w, entry.w);
|
|
76
|
+
if (width >= entry.w && entry.fmt === 'webp')
|
|
77
|
+
return { data: inputBuffer, format };
|
|
78
|
+
const file = join(dir, variantFileName(sha, width));
|
|
79
|
+
if (!existsSync(file)) {
|
|
80
|
+
throw new Error(`Missing image variant ${variantFileName(sha, width)} for ${entry.src.join(', ')} — run \`npm run images:variants\` and commit ${dir}.`);
|
|
81
|
+
}
|
|
82
|
+
return { data: readFileSync(file), format };
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
export default service;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type VariantManifest } from './index.ts';
|
|
2
|
+
/** Matches the WebP settings the sharp service used, so variants look like what the site served before noop. */
|
|
3
|
+
export declare const VARIANT_WEBP: {
|
|
4
|
+
readonly quality: 65;
|
|
5
|
+
readonly effort: 6;
|
|
6
|
+
};
|
|
7
|
+
export interface SourceImage {
|
|
8
|
+
/** Path relative to src/assets, always with forward slashes. */
|
|
9
|
+
rel: string;
|
|
10
|
+
sha: string;
|
|
11
|
+
bytes: Buffer;
|
|
12
|
+
}
|
|
13
|
+
/** Whether a path under src/assets gets variants: a raster image outside any gallery directory. */
|
|
14
|
+
export declare function isEligibleSource(rel: string): boolean;
|
|
15
|
+
export declare function listSources(siteRoot: string): string[];
|
|
16
|
+
export declare const sourceSha: (bytes: Uint8Array) => string;
|
|
17
|
+
export declare function readSource(siteRoot: string, rel: string): SourceImage;
|
|
18
|
+
export declare function parseManifest(text: string | null, where: string): VariantManifest;
|
|
19
|
+
export declare function readManifest(siteRoot: string): VariantManifest;
|
|
20
|
+
/** What the gate reads: the working tree (the CLI) or a pushed commit (the site pre-push hook). */
|
|
21
|
+
export interface VariantTree {
|
|
22
|
+
/** Eligible source paths relative to src/assets. */
|
|
23
|
+
sources(): string[];
|
|
24
|
+
sourceBytes(rel: string): Uint8Array;
|
|
25
|
+
manifestText(): string | null;
|
|
26
|
+
/** File names present in the variants directory. */
|
|
27
|
+
variantFiles(): string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function workingTree(siteRoot: string): VariantTree;
|
|
30
|
+
export interface GenerateResult {
|
|
31
|
+
sources: number;
|
|
32
|
+
written: number;
|
|
33
|
+
kept: number;
|
|
34
|
+
pruned: number;
|
|
35
|
+
}
|
|
36
|
+
/** Write every missing variant, rebuild the manifest, and delete variants no source needs any more. */
|
|
37
|
+
export declare function generateVariants(siteRoot: string, log?: (line: string) => void): Promise<GenerateResult>;
|
|
38
|
+
export interface CheckResult {
|
|
39
|
+
sources: number;
|
|
40
|
+
variants: number;
|
|
41
|
+
problems: string[];
|
|
42
|
+
orphans: string[];
|
|
43
|
+
}
|
|
44
|
+
/** The gate: every eligible source is in the manifest and every rung it needs has a committed file. */
|
|
45
|
+
export declare function checkVariants(tree: VariantTree): CheckResult;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The local generator and the gate for committed image variants. sharp is loaded only here, from the
|
|
2
|
+
// site's own install (Astro ships it), so CI never needs it.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { availableParallelism } from 'node:os';
|
|
6
|
+
import { join, relative, sep } from 'node:path';
|
|
7
|
+
import { EXCLUDED_DIR, MANIFEST_FILE, VARIANTS_DIR, variantFileName, variantRungs, } from "./index.js";
|
|
8
|
+
const SOURCE_EXT = /\.(webp|jpe?g|png|avif|tiff?)$/i;
|
|
9
|
+
/** Matches the WebP settings the sharp service used, so variants look like what the site served before noop. */
|
|
10
|
+
export const VARIANT_WEBP = { quality: 65, effort: 6 };
|
|
11
|
+
/** Whether a path under src/assets gets variants: a raster image outside any gallery directory. */
|
|
12
|
+
export function isEligibleSource(rel) {
|
|
13
|
+
return SOURCE_EXT.test(rel) && !EXCLUDED_DIR.test(rel) && !rel.split('/').some((seg) => seg.startsWith('.'));
|
|
14
|
+
}
|
|
15
|
+
export function listSources(siteRoot) {
|
|
16
|
+
const assets = join(siteRoot, 'src', 'assets');
|
|
17
|
+
if (!existsSync(assets))
|
|
18
|
+
throw new Error(`no src/assets directory under ${siteRoot}`);
|
|
19
|
+
const out = [];
|
|
20
|
+
const walk = (dir) => {
|
|
21
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
22
|
+
const abs = join(dir, e.name);
|
|
23
|
+
if (e.isDirectory())
|
|
24
|
+
walk(abs);
|
|
25
|
+
else
|
|
26
|
+
out.push(relative(assets, abs).split(sep).join('/'));
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
walk(assets);
|
|
30
|
+
return out.filter(isEligibleSource).sort();
|
|
31
|
+
}
|
|
32
|
+
export const sourceSha = (bytes) => createHash('sha256').update(bytes).digest('hex').slice(0, 16);
|
|
33
|
+
export function readSource(siteRoot, rel) {
|
|
34
|
+
const bytes = readFileSync(join(siteRoot, 'src', 'assets', rel));
|
|
35
|
+
return { rel, bytes, sha: sourceSha(bytes) };
|
|
36
|
+
}
|
|
37
|
+
export function parseManifest(text, where) {
|
|
38
|
+
if (text === null)
|
|
39
|
+
return {};
|
|
40
|
+
const parsed = JSON.parse(text);
|
|
41
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
42
|
+
throw new Error(`${where} is not a variant manifest object`);
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
export function readManifest(siteRoot) {
|
|
46
|
+
return parseManifest(workingTree(siteRoot).manifestText(), join(siteRoot, VARIANTS_DIR, MANIFEST_FILE));
|
|
47
|
+
}
|
|
48
|
+
export function workingTree(siteRoot) {
|
|
49
|
+
const dir = join(siteRoot, VARIANTS_DIR);
|
|
50
|
+
return {
|
|
51
|
+
sources: () => listSources(siteRoot),
|
|
52
|
+
sourceBytes: (rel) => readFileSync(join(siteRoot, 'src', 'assets', rel)),
|
|
53
|
+
manifestText: () => (existsSync(join(dir, MANIFEST_FILE)) ? readFileSync(join(dir, MANIFEST_FILE), 'utf-8') : null),
|
|
54
|
+
variantFiles: () => (existsSync(dir) ? readdirSync(dir) : []),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async function pool(items, fn) {
|
|
58
|
+
let next = 0;
|
|
59
|
+
const worker = async () => {
|
|
60
|
+
while (next < items.length)
|
|
61
|
+
await fn(items[next++]);
|
|
62
|
+
};
|
|
63
|
+
await Promise.all(Array.from({ length: Math.min(items.length, availableParallelism()) }, worker));
|
|
64
|
+
}
|
|
65
|
+
/** Write every missing variant, rebuild the manifest, and delete variants no source needs any more. */
|
|
66
|
+
export async function generateVariants(siteRoot, log = () => { }) {
|
|
67
|
+
const { default: sharp } = (await import('sharp'));
|
|
68
|
+
const dir = join(siteRoot, VARIANTS_DIR);
|
|
69
|
+
mkdirSync(dir, { recursive: true });
|
|
70
|
+
const previous = readManifest(siteRoot);
|
|
71
|
+
const manifest = {};
|
|
72
|
+
const rels = listSources(siteRoot);
|
|
73
|
+
let written = 0;
|
|
74
|
+
let kept = 0;
|
|
75
|
+
await pool(rels, async (rel) => {
|
|
76
|
+
const source = readSource(siteRoot, rel);
|
|
77
|
+
const shared = manifest[source.sha];
|
|
78
|
+
if (shared) {
|
|
79
|
+
shared.src.push(rel);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Reserve the sha before any await, so a duplicate file in another worker joins this entry.
|
|
83
|
+
const entry = { w: 0, h: 0, fmt: '', rungs: [], src: [rel] };
|
|
84
|
+
manifest[source.sha] = entry;
|
|
85
|
+
const known = previous[source.sha];
|
|
86
|
+
if (known) {
|
|
87
|
+
Object.assign(entry, { w: known.w, h: known.h, fmt: known.fmt, rungs: variantRungs(known.w, known.fmt) });
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
const meta = await sharp(source.bytes).metadata();
|
|
91
|
+
Object.assign(entry, { w: meta.width, h: meta.height, fmt: meta.format, rungs: variantRungs(meta.width, meta.format) });
|
|
92
|
+
}
|
|
93
|
+
for (const width of entry.rungs) {
|
|
94
|
+
const file = join(dir, variantFileName(source.sha, width));
|
|
95
|
+
if (existsSync(file)) {
|
|
96
|
+
kept++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
writeFileSync(file, await sharp(source.bytes).resize({ width }).webp(VARIANT_WEBP).toBuffer());
|
|
100
|
+
written++;
|
|
101
|
+
log(`+ ${variantFileName(source.sha, width)} ${rel}`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
const wanted = new Set(Object.entries(manifest).flatMap(([sha, e]) => e.rungs.map((w) => variantFileName(sha, w))));
|
|
105
|
+
let pruned = 0;
|
|
106
|
+
for (const name of readdirSync(dir)) {
|
|
107
|
+
if (name === MANIFEST_FILE || wanted.has(name))
|
|
108
|
+
continue;
|
|
109
|
+
rmSync(join(dir, name));
|
|
110
|
+
pruned++;
|
|
111
|
+
log(`- ${name}`);
|
|
112
|
+
}
|
|
113
|
+
const sorted = {};
|
|
114
|
+
for (const sha of Object.keys(manifest).sort())
|
|
115
|
+
sorted[sha] = { ...manifest[sha], src: manifest[sha].src.sort() };
|
|
116
|
+
writeFileSync(join(dir, MANIFEST_FILE), `${JSON.stringify(sorted, null, 2)}\n`);
|
|
117
|
+
return { sources: rels.length, written, kept, pruned };
|
|
118
|
+
}
|
|
119
|
+
/** The gate: every eligible source is in the manifest and every rung it needs has a committed file. */
|
|
120
|
+
export function checkVariants(tree) {
|
|
121
|
+
const manifest = parseManifest(tree.manifestText(), `${VARIANTS_DIR}/${MANIFEST_FILE}`);
|
|
122
|
+
const present = new Set(tree.variantFiles());
|
|
123
|
+
const rels = tree.sources();
|
|
124
|
+
const problems = [];
|
|
125
|
+
const needed = new Set();
|
|
126
|
+
let variants = 0;
|
|
127
|
+
for (const rel of rels) {
|
|
128
|
+
const sha = sourceSha(tree.sourceBytes(rel));
|
|
129
|
+
const entry = manifest[sha];
|
|
130
|
+
if (!entry) {
|
|
131
|
+
problems.push(`${rel}: not in ${VARIANTS_DIR}/${MANIFEST_FILE} (new or changed image)`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
for (const width of variantRungs(entry.w, entry.fmt)) {
|
|
135
|
+
const name = variantFileName(sha, width);
|
|
136
|
+
needed.add(name);
|
|
137
|
+
if (present.has(name))
|
|
138
|
+
variants++;
|
|
139
|
+
else
|
|
140
|
+
problems.push(`${rel}: missing ${VARIANTS_DIR}/${name}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const orphans = [...present].filter((n) => n !== MANIFEST_FILE && !needed.has(n));
|
|
144
|
+
return { sources: rels.length, variants, problems, orphans };
|
|
145
|
+
}
|
|
@@ -28,8 +28,8 @@ type ZodBuilders = Pick<typeof Zod, 'discriminatedUnion' | 'object' | 'literal'
|
|
|
28
28
|
export declare function createInterludeSchema(z: ZodBuilders, config: InterludeSchemaConfig): Zod.ZodDiscriminatedUnion<[Zod.ZodObject<{
|
|
29
29
|
type: Zod.ZodLiteral<"quote">;
|
|
30
30
|
variant: Zod.ZodEnum<{
|
|
31
|
-
pull: "pull";
|
|
32
31
|
external: "external";
|
|
32
|
+
pull: "pull";
|
|
33
33
|
}>;
|
|
34
34
|
content: Zod.ZodString;
|
|
35
35
|
attribution: Zod.ZodOptional<Zod.ZodObject<{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Non-visual utilities, Astro config builders and SEO schema assembly shared by the content sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -65,14 +65,35 @@
|
|
|
65
65
|
"./interludes": {
|
|
66
66
|
"types": "./dist/interludes/index.d.ts",
|
|
67
67
|
"default": "./dist/interludes/index.js"
|
|
68
|
+
},
|
|
69
|
+
"./images": {
|
|
70
|
+
"types": "./dist/images/index.d.ts",
|
|
71
|
+
"default": "./dist/images/index.js"
|
|
72
|
+
},
|
|
73
|
+
"./images/service": {
|
|
74
|
+
"types": "./dist/images/service.d.ts",
|
|
75
|
+
"default": "./dist/images/service.js"
|
|
76
|
+
},
|
|
77
|
+
"./images/variants": {
|
|
78
|
+
"types": "./dist/images/variants.d.ts",
|
|
79
|
+
"default": "./dist/images/variants.js"
|
|
68
80
|
}
|
|
69
81
|
},
|
|
82
|
+
"bin": {
|
|
83
|
+
"content-sites-image-variants": "./dist/images/cli.js"
|
|
84
|
+
},
|
|
70
85
|
"dependencies": {
|
|
71
86
|
"marked": "^18.0.14"
|
|
72
87
|
},
|
|
73
88
|
"peerDependencies": {
|
|
74
89
|
"astro": "^7.0.6",
|
|
75
90
|
"@astrojs/sitemap": "^3.7.4",
|
|
76
|
-
"@tailwindcss/vite": "~4.3.3"
|
|
91
|
+
"@tailwindcss/vite": "~4.3.3",
|
|
92
|
+
"sharp": ">=0.34.0"
|
|
93
|
+
},
|
|
94
|
+
"peerDependenciesMeta": {
|
|
95
|
+
"sharp": {
|
|
96
|
+
"optional": true
|
|
97
|
+
}
|
|
77
98
|
}
|
|
78
99
|
}
|
package/src/config/siteConfig.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { outboundLinkRel } from './outboundLinkRel.ts';
|
|
|
8
8
|
import { guideDateMap } from './guideDates.ts';
|
|
9
9
|
|
|
10
10
|
import { EPOCH } from '../dates/lastmod.ts';
|
|
11
|
+
import { VARIANTS_DIR } from '../images/index.ts';
|
|
11
12
|
|
|
12
13
|
export interface SitemapRule {
|
|
13
14
|
match: (parts: string[]) => boolean;
|
|
@@ -148,14 +149,10 @@ export function defineSiteConfig(opts: SiteConfigOptions): AstroUserConfig {
|
|
|
148
149
|
trailingSlash: 'never',
|
|
149
150
|
compressHTML: true,
|
|
150
151
|
image: {
|
|
152
|
+
// Never encodes: serves the committed variants in image-variants/ (npm run images:variants writes them).
|
|
151
153
|
service: {
|
|
152
|
-
entrypoint:
|
|
153
|
-
config: {
|
|
154
|
-
limitInputPixels: false,
|
|
155
|
-
avif: { quality: 65, effort: 6, chromaSubsampling: '4:2:0' },
|
|
156
|
-
// Astro re-encodes from decoded pixels, so the delivered quality is set here, not by the source file.
|
|
157
|
-
webp: { quality: 65, effort: 6 },
|
|
158
|
-
},
|
|
154
|
+
entrypoint: '@sarimarcus/content-sites-core/images/service',
|
|
155
|
+
config: { variantsDir: path.join(rootDir, VARIANTS_DIR) },
|
|
159
156
|
},
|
|
160
157
|
},
|
|
161
158
|
build: {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// content-sites-image-variants [--check] [--root=<site dir>]
|
|
3
|
+
// (default) write missing variants, prune unused ones, rewrite the manifest
|
|
4
|
+
// --check the pre-push gate: exit 1 when any eligible source lacks a variant
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { VARIANTS_DIR } from './index.ts';
|
|
7
|
+
import { checkVariants, generateVariants, workingTree } from './variants.ts';
|
|
8
|
+
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
const unknown = args.filter((a) => a !== '--check' && !a.startsWith('--root='));
|
|
11
|
+
if (unknown.length) {
|
|
12
|
+
console.error(`unknown argument(s): ${unknown.join(' ')}\nUsage: content-sites-image-variants [--check] [--root=<site dir>]`);
|
|
13
|
+
process.exit(2);
|
|
14
|
+
}
|
|
15
|
+
const root = resolve(args.find((a) => a.startsWith('--root='))?.slice('--root='.length) ?? process.cwd());
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
if (args.includes('--check')) {
|
|
19
|
+
const r = checkVariants(workingTree(root));
|
|
20
|
+
if (!r.sources) {
|
|
21
|
+
console.error(`✗ no eligible images under ${root}/src/assets — nothing examined, not a pass`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
if (r.orphans.length) console.warn(`⚠ ${r.orphans.length} unused variant file(s) in ${VARIANTS_DIR}/ — npm run images:variants prunes them`);
|
|
25
|
+
if (r.problems.length) {
|
|
26
|
+
for (const p of r.problems.slice(0, 50)) console.error(` ${p}`);
|
|
27
|
+
if (r.problems.length > 50) console.error(` … and ${r.problems.length - 50} more`);
|
|
28
|
+
console.error(`✗ ${r.problems.length} image variant problem(s) across ${r.sources} source(s) — run \`npm run images:variants\` and commit ${VARIANTS_DIR}/`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
console.log(`✓ ${r.sources} source image(s) checked, ${r.variants} committed variant(s) present`);
|
|
32
|
+
} else {
|
|
33
|
+
const r = await generateVariants(root, (line) => console.log(line));
|
|
34
|
+
console.log(`✓ ${r.sources} source image(s): ${r.written} variant(s) written, ${r.kept} already present, ${r.pruned} pruned`);
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error(`✗ ${(err as Error).message}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The one width ladder every responsive image uses. WebP only. Not frozen: Astro sorts `widths` in place. */
|
|
2
|
+
export const IMAGE_LADDER: number[] = [320, 480, 640, 960, 1280];
|
|
3
|
+
|
|
4
|
+
/** Where a site keeps its committed variants, relative to the site root. */
|
|
5
|
+
export const VARIANTS_DIR = 'image-variants';
|
|
6
|
+
export const MANIFEST_FILE = 'manifest.json';
|
|
7
|
+
|
|
8
|
+
/** Directories under src/assets whose images get no variants (galleries). */
|
|
9
|
+
export const EXCLUDED_DIR = /(^|\/)([^/]*-)?gallery(\/|$)/;
|
|
10
|
+
|
|
11
|
+
export interface VariantEntry {
|
|
12
|
+
w: number;
|
|
13
|
+
h: number;
|
|
14
|
+
fmt: string;
|
|
15
|
+
/** Widths with a committed file. */
|
|
16
|
+
rungs: number[];
|
|
17
|
+
/** Paths relative to src/assets that share these bytes. */
|
|
18
|
+
src: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type VariantManifest = Record<string, VariantEntry>;
|
|
22
|
+
|
|
23
|
+
/** The srcset widths for an image `native` px wide: the rungs below it, then the native width. */
|
|
24
|
+
export function ladderWidths(native: number): number[] {
|
|
25
|
+
return [...IMAGE_LADDER.filter((w) => w < native), native];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The width actually delivered for a requested width: the smallest srcset entry at least that wide. */
|
|
29
|
+
export function snapWidth(requested: number, native: number): number {
|
|
30
|
+
return ladderWidths(native).find((w) => w >= requested) ?? native;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The widths that need a committed file; the native entry is served from the source bytes when it is WebP. */
|
|
34
|
+
export function variantRungs(native: number, format: string): number[] {
|
|
35
|
+
const widths = ladderWidths(native);
|
|
36
|
+
return format === 'webp' ? widths.slice(0, -1) : widths;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function variantFileName(sha: string, width: number): string {
|
|
40
|
+
return `${sha}-${width}.webp`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Astro image service for production builds: never encodes. It returns the committed variants the
|
|
2
|
+
// generator wrote (variants.ts) and the source bytes for the native width, so CI stays as fast as noop
|
|
3
|
+
// while srcset entries point at genuinely different files.
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
import { baseService } from 'astro/assets';
|
|
7
|
+
import type { ImageMetadata, ImageOutputFormat, LocalImageService } from 'astro';
|
|
8
|
+
import { MANIFEST_FILE, ladderWidths, snapWidth, variantFileName, type VariantManifest } from './index.ts';
|
|
9
|
+
import { parseManifest, sourceSha } from './variants.ts';
|
|
10
|
+
|
|
11
|
+
export interface VariantServiceConfig {
|
|
12
|
+
/** Absolute path to the site's committed variants. */
|
|
13
|
+
variantsDir: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const manifests = new Map<string, VariantManifest>();
|
|
17
|
+
function manifestFor(dir: string): VariantManifest {
|
|
18
|
+
let manifest = manifests.get(dir);
|
|
19
|
+
if (!manifest) {
|
|
20
|
+
const file = join(dir, MANIFEST_FILE);
|
|
21
|
+
manifest = parseManifest(existsSync(file) ? readFileSync(file, 'utf-8') : null, file);
|
|
22
|
+
manifests.set(dir, manifest);
|
|
23
|
+
}
|
|
24
|
+
return manifest;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// getImage hands getSrcSet a clone of the import without its fsPath, so coverage is matched on file stem + size.
|
|
28
|
+
const coverKey = (stem: string, w: number, h: number) => `${stem}|${w}x${h}`;
|
|
29
|
+
const covered = new Map<string, Set<string>>();
|
|
30
|
+
function isCovered(dir: string, src: ImageMetadata): boolean {
|
|
31
|
+
let set = covered.get(dir);
|
|
32
|
+
if (!set) {
|
|
33
|
+
set = new Set(Object.values(manifestFor(dir)).flatMap((e) => e.src.map((rel) => coverKey(basename(rel).replace(/\.[^.]+$/, ''), e.w, e.h))));
|
|
34
|
+
covered.set(dir, set);
|
|
35
|
+
}
|
|
36
|
+
// Built: /_astro/<stem>.<hash>.webp; dev: /@fs/…/<stem>.webp?origWidth=…
|
|
37
|
+
const file = basename(src.src.split('?')[0]).replace(/\.[^.]+$/, '');
|
|
38
|
+
return set.has(coverKey(file, src.width, src.height)) || set.has(coverKey(file.replace(/\.[^.]+$/, ''), src.width, src.height));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isImported = (src: unknown): src is ImageMetadata => typeof src === 'object' && src !== null;
|
|
42
|
+
|
|
43
|
+
const service: LocalImageService<VariantServiceConfig> = {
|
|
44
|
+
...baseService,
|
|
45
|
+
propertiesToHash: ['src', 'width', 'format'],
|
|
46
|
+
|
|
47
|
+
async validateOptions(options, imageConfig, logger) {
|
|
48
|
+
if (isImported(options.src) && options.src.format === 'svg') {
|
|
49
|
+
options.format = 'svg';
|
|
50
|
+
return options;
|
|
51
|
+
}
|
|
52
|
+
if (options.format && options.format !== 'webp') {
|
|
53
|
+
throw new Error(`Image format "${options.format}" is not served: the variant service delivers WebP only (src: ${isImported(options.src) ? options.src.src : options.src}).`);
|
|
54
|
+
}
|
|
55
|
+
options.format = 'webp';
|
|
56
|
+
return baseService.validateOptions!(options, imageConfig, logger);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
// Passing `widths` (always IMAGE_LADDER) asks for a responsive image; the entries come from the ladder, capped at the source.
|
|
60
|
+
// An image with no variants (a gallery) gets no srcset rather than one that repeats a single file.
|
|
61
|
+
getSrcSet(options, imageConfig) {
|
|
62
|
+
if (!options.widths?.length || !isImported(options.src)) return [];
|
|
63
|
+
if (!isCovered(imageConfig.service.config.variantsDir, options.src)) return [];
|
|
64
|
+
const { width: native, height } = options.src;
|
|
65
|
+
const { width: _w, height: _h, widths: _ws, densities: _d, ...rest } = options;
|
|
66
|
+
return ladderWidths(native).map((width) => ({
|
|
67
|
+
transform: { ...rest, width, height: Math.round((width * height) / native) },
|
|
68
|
+
descriptor: `${width}w`,
|
|
69
|
+
attributes: { type: 'image/webp' },
|
|
70
|
+
}));
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async transform(inputBuffer, transform, imageConfig, logger) {
|
|
74
|
+
const format = transform.format as ImageOutputFormat;
|
|
75
|
+
if (format === 'svg') return { data: inputBuffer, format };
|
|
76
|
+
const dir = imageConfig.service.config.variantsDir;
|
|
77
|
+
const sha = sourceSha(inputBuffer);
|
|
78
|
+
const entry = manifestFor(dir)[sha];
|
|
79
|
+
if (!entry) {
|
|
80
|
+
// A gallery (excluded by design), or an image nobody ran images:variants for; the push gate refuses the latter.
|
|
81
|
+
logger.warn(`no variants for ${transform.src}; serving the source bytes`);
|
|
82
|
+
return { data: inputBuffer, format };
|
|
83
|
+
}
|
|
84
|
+
// An off-ladder width (an avatar's width={64}) keeps its HTML attributes and gets the next file up.
|
|
85
|
+
const width = snapWidth(Number(transform.width) || entry.w, entry.w);
|
|
86
|
+
if (width >= entry.w && entry.fmt === 'webp') return { data: inputBuffer, format };
|
|
87
|
+
const file = join(dir, variantFileName(sha, width));
|
|
88
|
+
if (!existsSync(file)) {
|
|
89
|
+
throw new Error(`Missing image variant ${variantFileName(sha, width)} for ${entry.src.join(', ')} — run \`npm run images:variants\` and commit ${dir}.`);
|
|
90
|
+
}
|
|
91
|
+
return { data: readFileSync(file), format };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export default service;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The local generator and the gate for committed image variants. sharp is loaded only here, from the
|
|
2
|
+
// site's own install (Astro ships it), so CI never needs it.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { availableParallelism } from 'node:os';
|
|
6
|
+
import { join, relative, sep } from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
EXCLUDED_DIR, MANIFEST_FILE, VARIANTS_DIR, variantFileName, variantRungs,
|
|
9
|
+
type VariantEntry, type VariantManifest,
|
|
10
|
+
} from './index.ts';
|
|
11
|
+
|
|
12
|
+
const SOURCE_EXT = /\.(webp|jpe?g|png|avif|tiff?)$/i;
|
|
13
|
+
/** Matches the WebP settings the sharp service used, so variants look like what the site served before noop. */
|
|
14
|
+
export const VARIANT_WEBP = { quality: 65, effort: 6 } as const;
|
|
15
|
+
|
|
16
|
+
export interface SourceImage {
|
|
17
|
+
/** Path relative to src/assets, always with forward slashes. */
|
|
18
|
+
rel: string;
|
|
19
|
+
sha: string;
|
|
20
|
+
bytes: Buffer;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether a path under src/assets gets variants: a raster image outside any gallery directory. */
|
|
24
|
+
export function isEligibleSource(rel: string): boolean {
|
|
25
|
+
return SOURCE_EXT.test(rel) && !EXCLUDED_DIR.test(rel) && !rel.split('/').some((seg) => seg.startsWith('.'));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function listSources(siteRoot: string): string[] {
|
|
29
|
+
const assets = join(siteRoot, 'src', 'assets');
|
|
30
|
+
if (!existsSync(assets)) throw new Error(`no src/assets directory under ${siteRoot}`);
|
|
31
|
+
const out: string[] = [];
|
|
32
|
+
const walk = (dir: string) => {
|
|
33
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
34
|
+
const abs = join(dir, e.name);
|
|
35
|
+
if (e.isDirectory()) walk(abs);
|
|
36
|
+
else out.push(relative(assets, abs).split(sep).join('/'));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(assets);
|
|
40
|
+
return out.filter(isEligibleSource).sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const sourceSha = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex').slice(0, 16);
|
|
44
|
+
|
|
45
|
+
export function readSource(siteRoot: string, rel: string): SourceImage {
|
|
46
|
+
const bytes = readFileSync(join(siteRoot, 'src', 'assets', rel));
|
|
47
|
+
return { rel, bytes, sha: sourceSha(bytes) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseManifest(text: string | null, where: string): VariantManifest {
|
|
51
|
+
if (text === null) return {};
|
|
52
|
+
const parsed = JSON.parse(text);
|
|
53
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${where} is not a variant manifest object`);
|
|
54
|
+
return parsed as VariantManifest;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readManifest(siteRoot: string): VariantManifest {
|
|
58
|
+
return parseManifest(workingTree(siteRoot).manifestText(), join(siteRoot, VARIANTS_DIR, MANIFEST_FILE));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What the gate reads: the working tree (the CLI) or a pushed commit (the site pre-push hook). */
|
|
62
|
+
export interface VariantTree {
|
|
63
|
+
/** Eligible source paths relative to src/assets. */
|
|
64
|
+
sources(): string[];
|
|
65
|
+
sourceBytes(rel: string): Uint8Array;
|
|
66
|
+
manifestText(): string | null;
|
|
67
|
+
/** File names present in the variants directory. */
|
|
68
|
+
variantFiles(): string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function workingTree(siteRoot: string): VariantTree {
|
|
72
|
+
const dir = join(siteRoot, VARIANTS_DIR);
|
|
73
|
+
return {
|
|
74
|
+
sources: () => listSources(siteRoot),
|
|
75
|
+
sourceBytes: (rel) => readFileSync(join(siteRoot, 'src', 'assets', rel)),
|
|
76
|
+
manifestText: () => (existsSync(join(dir, MANIFEST_FILE)) ? readFileSync(join(dir, MANIFEST_FILE), 'utf-8') : null),
|
|
77
|
+
variantFiles: () => (existsSync(dir) ? readdirSync(dir) : []),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function pool<T>(items: T[], fn: (item: T) => Promise<void>): Promise<void> {
|
|
82
|
+
let next = 0;
|
|
83
|
+
const worker = async () => {
|
|
84
|
+
while (next < items.length) await fn(items[next++]);
|
|
85
|
+
};
|
|
86
|
+
await Promise.all(Array.from({ length: Math.min(items.length, availableParallelism()) }, worker));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface GenerateResult {
|
|
90
|
+
sources: number;
|
|
91
|
+
written: number;
|
|
92
|
+
kept: number;
|
|
93
|
+
pruned: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Write every missing variant, rebuild the manifest, and delete variants no source needs any more. */
|
|
97
|
+
export async function generateVariants(siteRoot: string, log: (line: string) => void = () => {}): Promise<GenerateResult> {
|
|
98
|
+
const { default: sharp } = (await import('sharp')) as { default: any };
|
|
99
|
+
const dir = join(siteRoot, VARIANTS_DIR);
|
|
100
|
+
mkdirSync(dir, { recursive: true });
|
|
101
|
+
const previous = readManifest(siteRoot);
|
|
102
|
+
const manifest: VariantManifest = {};
|
|
103
|
+
const rels = listSources(siteRoot);
|
|
104
|
+
let written = 0;
|
|
105
|
+
let kept = 0;
|
|
106
|
+
await pool(rels, async (rel) => {
|
|
107
|
+
const source = readSource(siteRoot, rel);
|
|
108
|
+
const shared = manifest[source.sha];
|
|
109
|
+
if (shared) {
|
|
110
|
+
shared.src.push(rel);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Reserve the sha before any await, so a duplicate file in another worker joins this entry.
|
|
114
|
+
const entry: VariantEntry = { w: 0, h: 0, fmt: '', rungs: [], src: [rel] };
|
|
115
|
+
manifest[source.sha] = entry;
|
|
116
|
+
const known = previous[source.sha];
|
|
117
|
+
if (known) {
|
|
118
|
+
Object.assign(entry, { w: known.w, h: known.h, fmt: known.fmt, rungs: variantRungs(known.w, known.fmt) });
|
|
119
|
+
} else {
|
|
120
|
+
const meta = await sharp(source.bytes).metadata();
|
|
121
|
+
Object.assign(entry, { w: meta.width, h: meta.height, fmt: meta.format, rungs: variantRungs(meta.width, meta.format) });
|
|
122
|
+
}
|
|
123
|
+
for (const width of entry.rungs) {
|
|
124
|
+
const file = join(dir, variantFileName(source.sha, width));
|
|
125
|
+
if (existsSync(file)) {
|
|
126
|
+
kept++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
writeFileSync(file, await sharp(source.bytes).resize({ width }).webp(VARIANT_WEBP).toBuffer());
|
|
130
|
+
written++;
|
|
131
|
+
log(`+ ${variantFileName(source.sha, width)} ${rel}`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
const wanted = new Set(Object.entries(manifest).flatMap(([sha, e]) => e.rungs.map((w) => variantFileName(sha, w))));
|
|
135
|
+
let pruned = 0;
|
|
136
|
+
for (const name of readdirSync(dir)) {
|
|
137
|
+
if (name === MANIFEST_FILE || wanted.has(name)) continue;
|
|
138
|
+
rmSync(join(dir, name));
|
|
139
|
+
pruned++;
|
|
140
|
+
log(`- ${name}`);
|
|
141
|
+
}
|
|
142
|
+
const sorted: VariantManifest = {};
|
|
143
|
+
for (const sha of Object.keys(manifest).sort()) sorted[sha] = { ...manifest[sha], src: manifest[sha].src.sort() };
|
|
144
|
+
writeFileSync(join(dir, MANIFEST_FILE), `${JSON.stringify(sorted, null, 2)}\n`);
|
|
145
|
+
return { sources: rels.length, written, kept, pruned };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface CheckResult {
|
|
149
|
+
sources: number;
|
|
150
|
+
variants: number;
|
|
151
|
+
problems: string[];
|
|
152
|
+
orphans: string[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The gate: every eligible source is in the manifest and every rung it needs has a committed file. */
|
|
156
|
+
export function checkVariants(tree: VariantTree): CheckResult {
|
|
157
|
+
const manifest = parseManifest(tree.manifestText(), `${VARIANTS_DIR}/${MANIFEST_FILE}`);
|
|
158
|
+
const present = new Set(tree.variantFiles());
|
|
159
|
+
const rels = tree.sources();
|
|
160
|
+
const problems: string[] = [];
|
|
161
|
+
const needed = new Set<string>();
|
|
162
|
+
let variants = 0;
|
|
163
|
+
for (const rel of rels) {
|
|
164
|
+
const sha = sourceSha(tree.sourceBytes(rel));
|
|
165
|
+
const entry = manifest[sha];
|
|
166
|
+
if (!entry) {
|
|
167
|
+
problems.push(`${rel}: not in ${VARIANTS_DIR}/${MANIFEST_FILE} (new or changed image)`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
for (const width of variantRungs(entry.w, entry.fmt)) {
|
|
171
|
+
const name = variantFileName(sha, width);
|
|
172
|
+
needed.add(name);
|
|
173
|
+
if (present.has(name)) variants++;
|
|
174
|
+
else problems.push(`${rel}: missing ${VARIANTS_DIR}/${name}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const orphans = [...present].filter((n) => n !== MANIFEST_FILE && !needed.has(n));
|
|
178
|
+
return { sources: rels.length, variants, problems, orphans };
|
|
179
|
+
}
|