@sarimarcus/content-sites-core 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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<{
@@ -4,3 +4,4 @@ export { normalizeHref, extractProseHrefs, dedupRelated } from './relatedDedup.t
4
4
  export { buildImageMap, keyBySlug } from './imageMap.ts';
5
5
  export { articleFeedItem } from './feed.ts';
6
6
  export type { FeedArticle, FeedItem } from './feed.ts';
7
+ export { pageTypeOf } from './pageType.ts';
package/dist/url/index.js CHANGED
@@ -4,3 +4,4 @@ export { getOgImagePath } from "./ogImage.js";
4
4
  export { normalizeHref, extractProseHrefs, dedupRelated } from "./relatedDedup.js";
5
5
  export { buildImageMap, keyBySlug } from "./imageMap.js";
6
6
  export { articleFeedItem } from "./feed.js";
7
+ export { pageTypeOf } from "./pageType.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Page type: the first path segment, 'home' at the root.
3
+ *
4
+ * The single definition behind both the GA4 `page_type` param (runtime pathname, '/plan/x') and a site's GYG `cmp`
5
+ * segment (BUILD-time `Astro.url.pathname`, '/index.html' under build.format 'file'). The `.html`/`index`
6
+ * normalisation is what makes the two agree.
7
+ */
8
+ export declare function pageTypeOf(pathname: string): string;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Page type: the first path segment, 'home' at the root.
3
+ *
4
+ * The single definition behind both the GA4 `page_type` param (runtime pathname, '/plan/x') and a site's GYG `cmp`
5
+ * segment (BUILD-time `Astro.url.pathname`, '/index.html' under build.format 'file'). The `.html`/`index`
6
+ * normalisation is what makes the two agree.
7
+ */
8
+ export function pageTypeOf(pathname) {
9
+ const first = pathname.split('/').filter(Boolean)[0];
10
+ if (!first)
11
+ return 'home';
12
+ const bare = first.replace(/\.html$/, '');
13
+ return bare === 'index' || bare === '' ? 'home' : bare;
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-core",
3
- "version": "0.20.0",
3
+ "version": "0.22.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",
@@ -34,6 +34,10 @@
34
34
  "types": "./dist/url/index.d.ts",
35
35
  "default": "./dist/url/index.js"
36
36
  },
37
+ "./affiliate": {
38
+ "types": "./dist/affiliate/index.d.ts",
39
+ "default": "./dist/affiliate/index.js"
40
+ },
37
41
  "./dates": {
38
42
  "types": "./dist/dates/index.d.ts",
39
43
  "default": "./dist/dates/index.js"
@@ -65,14 +69,35 @@
65
69
  "./interludes": {
66
70
  "types": "./dist/interludes/index.d.ts",
67
71
  "default": "./dist/interludes/index.js"
72
+ },
73
+ "./images": {
74
+ "types": "./dist/images/index.d.ts",
75
+ "default": "./dist/images/index.js"
76
+ },
77
+ "./images/service": {
78
+ "types": "./dist/images/service.d.ts",
79
+ "default": "./dist/images/service.js"
80
+ },
81
+ "./images/variants": {
82
+ "types": "./dist/images/variants.d.ts",
83
+ "default": "./dist/images/variants.js"
68
84
  }
69
85
  },
86
+ "bin": {
87
+ "content-sites-image-variants": "./dist/images/cli.js"
88
+ },
70
89
  "dependencies": {
71
90
  "marked": "^18.0.14"
72
91
  },
73
92
  "peerDependencies": {
74
93
  "astro": "^7.0.6",
75
94
  "@astrojs/sitemap": "^3.7.4",
76
- "@tailwindcss/vite": "~4.3.3"
95
+ "@tailwindcss/vite": "~4.3.3",
96
+ "sharp": ">=0.34.0"
97
+ },
98
+ "peerDependenciesMeta": {
99
+ "sharp": {
100
+ "optional": true
101
+ }
77
102
  }
78
103
  }
@@ -0,0 +1,66 @@
1
+ import { pageTypeOf } from '../url/pageType.ts';
2
+
3
+ const GYG_HOST = /(^|\.)getyourguide\.com$/;
4
+
5
+ /** GetYourGuide product id: the `-t<digits>` segment of a tour URL. */
6
+ export function gygProductId(url: string): string {
7
+ return url.match(/-t(\d+)/)?.[1] ?? '';
8
+ }
9
+
10
+ /**
11
+ * cmp segments are lowercased and non-alphanumerics collapse to a single hyphen.
12
+ *
13
+ * Exported so a component can stamp the SAME normalised string into its data-track-*-placement attributes.
14
+ */
15
+ export function cmpSegment(raw: string): string {
16
+ return raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
17
+ }
18
+
19
+ export interface GygCmp {
20
+ /** Append `cmp=<siteKey>__<pageType>__<placement>` to a GetYourGuide affiliate URL. */
21
+ withGygCmp(href: string, pathname: string, placement: string): string;
22
+ /** Tag every GetYourGuide partner link inside already-rendered HTML. */
23
+ tagGygLinksInHtml(html: string, pathname: string, placement?: string): string;
24
+ }
25
+
26
+ /**
27
+ * The GYG campaign tagger for one site. `siteKey` is the first cmp segment; scripts/validate-gyg-cmp.mjs enforces
28
+ * the `<siteKey>__<pageType>__<placement>` shape against the built dist.
29
+ *
30
+ * Three deliberate no-ops, each a wrong-attribution bug if dropped:
31
+ * - non-GYG hosts pass through (cmp is a GYG parameter, meaningless elsewhere);
32
+ * - GYG URLs with no partner id pass through: those are editorial citations, not monetized CTAs;
33
+ * - an existing `cmp` is left alone, so tagging is idempotent. That is not an escape hatch for a hand-set
34
+ * campaign value: the gate refuses any other shape.
35
+ *
36
+ * The cmp is appended textually rather than via `searchParams.set` + `toString()`, because that round-trip
37
+ * re-serializes the whole query string (`extra=a%20b` becomes `extra=a+b`).
38
+ */
39
+ export function createGygCmp(siteKey: string): GygCmp {
40
+ function withGygCmp(href: string, pathname: string, placement: string): string {
41
+ let u: URL;
42
+ try { u = new URL(href); } catch { return href; }
43
+ if (!GYG_HOST.test(u.hostname.replace(/^www\./, ''))) return href;
44
+ if (!u.searchParams.has('partner_id')) return href;
45
+ if (u.searchParams.has('cmp')) return href;
46
+ const type = cmpSegment(pageTypeOf(pathname));
47
+ const place = cmpSegment(placement);
48
+ if (!type || !place) return href;
49
+ // cmp is [a-z0-9-] segments joined by `__`, so it needs no percent-encoding.
50
+ const hashAt = href.indexOf('#');
51
+ const base = hashAt === -1 ? href : href.slice(0, hashAt);
52
+ const frag = hashAt === -1 ? '' : href.slice(hashAt);
53
+ return `${base}${base.includes('?') ? '&' : '?'}cmp=${siteKey}__${type}__${place}${frag}`;
54
+ }
55
+
56
+ function tagGygLinksInHtml(html: string, pathname: string, placement = 'prose'): string {
57
+ return html.replace(
58
+ /href="(https?:\/\/[^"]*getyourguide\.com[^"]*)"/g,
59
+ // `&amp;` is the standard encoding inside an attribute; without decoding it first, new URL() reads the key
60
+ // as `amp;partner_id` and the link ships untagged.
61
+ (_m, href: string) => `href="${withGygCmp(href.replace(/&amp;/g, '&'), pathname, placement).replace(/&(?!amp;|lt;|gt;|quot;|apos;|#\d)/g, '&amp;')}"`,
62
+ );
63
+ }
64
+
65
+ return { withGygCmp, tagGygLinksInHtml };
66
+ }
@@ -0,0 +1,7 @@
1
+ // Public surface of @sarimarcus/content-sites-core/affiliate.
2
+ export { gygProductId, cmpSegment, createGygCmp } from './gyg.ts';
3
+ export type { GygCmp } from './gyg.ts';
4
+ export { amazonProductUrl, bookingSearchUrl } from './links.ts';
5
+ export type { BookingSearch } from './links.ts';
6
+ export { travelpayoutsWidgetSrc } from './travelpayouts.ts';
7
+ export type { WidgetLoader, WidgetAccount, WidgetOptions } from './travelpayouts.ts';
@@ -0,0 +1,35 @@
1
+ /** Amazon product page. With a tag (even an empty one) the path gains a trailing slash before the query; without one it has none. */
2
+ export function amazonProductUrl(asin: string, tag?: string): string {
3
+ const base = `https://www.amazon.com/dp/${asin}`;
4
+ return tag === undefined ? base : `${base}/?${new URLSearchParams({ tag })}`;
5
+ }
6
+
7
+ export interface BookingSearch {
8
+ /** Free text. Alone it resolves to a property; with `destId` the destination wins. */
9
+ ss: string;
10
+ destId?: string;
11
+ destType?: string;
12
+ checkin?: string;
13
+ checkout?: string;
14
+ }
15
+
16
+ /**
17
+ * A Booking.com search for two adults in one room.
18
+ *
19
+ * Carries no affiliate parameter: Travelpayouts' Emerald Link Switcher rewrites raw booking.com hrefs into tracked
20
+ * redirects at click time. Parameter order is fixed (ss, dest_id, dest_type, checkin, checkout, then the party).
21
+ */
22
+ export function bookingSearchUrl({ ss, destId, destType = 'city', checkin, checkout }: BookingSearch): string {
23
+ const params = new URLSearchParams({ ss });
24
+ if (destId) {
25
+ params.set('dest_id', destId);
26
+ params.set('dest_type', destType);
27
+ }
28
+ if (checkin) params.set('checkin', checkin);
29
+ if (checkout) params.set('checkout', checkout);
30
+ params.set('group_adults', '2');
31
+ params.set('group_children', '0');
32
+ params.set('no_rooms', '1');
33
+ params.set('lang', 'en-gb');
34
+ return `https://www.booking.com/searchresults.html?${params.toString()}`;
35
+ }
@@ -0,0 +1,62 @@
1
+ /** Where a Travelpayouts widget kind loads from and the ids it reports under. Declared in ui affiliate/resources.ts. */
2
+ export interface WidgetLoader {
3
+ /** The loader script URL without its query, e.g. `https://<host>/content`. */
4
+ url: string;
5
+ promoId: string;
6
+ campaignId?: string;
7
+ }
8
+
9
+ export interface WidgetAccount {
10
+ /** The site's traffic source, which splits revenue per site. */
11
+ trs: string;
12
+ /** The Travelpayouts marker. */
13
+ marker: string;
14
+ }
15
+
16
+ export type WidgetOptions =
17
+ | { kind: 'hotel'; direction: string }
18
+ | { kind: 'activity'; place: string }
19
+ | { kind: 'train' }
20
+ | {
21
+ kind: 'car';
22
+ country: string;
23
+ city: string;
24
+ colors: { background: string; font: string; button: string; buttonFont: string };
25
+ buttonText?: string;
26
+ };
27
+
28
+ type Pair = [string, string];
29
+
30
+ /**
31
+ * A Travelpayouts widget loader URL. Each kind keeps the parameter order Travelpayouts issued, which differs per kind
32
+ * (hotel puts promo_id before campaign_id, activity and car after).
33
+ */
34
+ export function travelpayoutsWidgetSrc(loader: WidgetLoader, account: WidgetAccount, options: WidgetOptions): string {
35
+ const ids = (order: 'promo-first' | 'campaign-first'): Pair[] => {
36
+ const promo: Pair = ['promo_id', loader.promoId];
37
+ const campaign: Pair[] = loader.campaignId ? [['campaign_id', loader.campaignId]] : [];
38
+ return order === 'promo-first' ? [promo, ...campaign] : [...campaign, promo];
39
+ };
40
+ const who: Pair[] = [['trs', account.trs], ['shmarker', account.marker]];
41
+ let pairs: Pair[];
42
+ switch (options.kind) {
43
+ case 'hotel':
44
+ pairs = [...who, ['locale', 'en'], ['default_direction', options.direction], ['sustainable', 'false'], ['deals', 'false'],
45
+ ['border_radius', '5'], ['plain', 'true'], ['powered_by', 'true'], ...ids('promo-first')];
46
+ break;
47
+ case 'activity':
48
+ pairs = [...who, ['place', options.place], ['items', '3'], ['locale', 'en-US'], ['powered_by', 'true'], ...ids('campaign-first')];
49
+ break;
50
+ case 'train':
51
+ pairs = [['currency', 'USD'], ...who, ['powered_by', 'true'], ['locale', 'en'], ['mode', 'default'], ['theme', 'white'],
52
+ ['layout', 'fluid'], ...ids('promo-first')];
53
+ break;
54
+ case 'car':
55
+ pairs = [['currency', 'usd'], ...who, ['country', options.country], ['city', options.city], ['locale', 'en'], ['powered_by', 'true'],
56
+ ['bg_color', options.colors.background], ['font_color', options.colors.font], ['button_color', options.colors.button],
57
+ ['button_font_color', options.colors.buttonFont], ['button_text', options.buttonText ?? 'Search'], ['rounded_corners', 'true'],
58
+ ['benefits', 'true'], ['dc_powered_by', 'false'], ['supplier_logos', 'false'], ...ids('campaign-first')];
59
+ break;
60
+ }
61
+ return `${loader.url}?${pairs.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}`;
62
+ }
@@ -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: process.env.CI ? 'astro/assets/services/noop' : 'astro/assets/services/sharp',
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;