@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.
- package/CHANGELOG.md +8 -0
- package/README.md +9 -1
- package/dist/affiliate/gyg.d.ts +28 -0
- package/dist/affiliate/gyg.js +60 -0
- package/dist/affiliate/index.d.ts +6 -0
- package/dist/affiliate/index.js +4 -0
- package/dist/affiliate/links.d.ts +17 -0
- package/dist/affiliate/links.js +27 -0
- package/dist/affiliate/travelpayouts.d.ts +38 -0
- package/dist/affiliate/travelpayouts.js +33 -0
- 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/dist/url/index.d.ts +1 -0
- package/dist/url/index.js +1 -0
- package/dist/url/pageType.d.ts +8 -0
- package/dist/url/pageType.js +14 -0
- package/package.json +27 -2
- package/src/affiliate/gyg.ts +66 -0
- package/src/affiliate/index.ts +7 -0
- package/src/affiliate/links.ts +35 -0
- package/src/affiliate/travelpayouts.ts +62 -0
- 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/src/url/index.ts +1 -0
- package/src/url/pageType.ts +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.22.0 (2026-10-04)
|
|
6
|
+
|
|
7
|
+
- New `./affiliate`: `createGygCmp(siteKey)` (the GYG `cmp=` tagger and `tagGygLinksInHtml`), `gygProductId` (one `-t<digits>` pattern for every site), `cmpSegment`, `amazonProductUrl`, `bookingSearchUrl` and `travelpayoutsWidgetSrc`. Sites pass their partner ids and destinations in; output is byte-identical to the per-site builders it replaces. `./url` now exports `pageTypeOf` (SLA-2919).
|
|
8
|
+
|
|
9
|
+
## 0.21.0 (2026-10-02)
|
|
10
|
+
|
|
11
|
+
- 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).
|
|
12
|
+
|
|
5
13
|
## 0.20.0 (2026-10-02)
|
|
6
14
|
|
|
7
15
|
- 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`, `affiliate`, `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`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** GetYourGuide product id: the `-t<digits>` segment of a tour URL. */
|
|
2
|
+
export declare function gygProductId(url: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* cmp segments are lowercased and non-alphanumerics collapse to a single hyphen.
|
|
5
|
+
*
|
|
6
|
+
* Exported so a component can stamp the SAME normalised string into its data-track-*-placement attributes.
|
|
7
|
+
*/
|
|
8
|
+
export declare function cmpSegment(raw: string): string;
|
|
9
|
+
export interface GygCmp {
|
|
10
|
+
/** Append `cmp=<siteKey>__<pageType>__<placement>` to a GetYourGuide affiliate URL. */
|
|
11
|
+
withGygCmp(href: string, pathname: string, placement: string): string;
|
|
12
|
+
/** Tag every GetYourGuide partner link inside already-rendered HTML. */
|
|
13
|
+
tagGygLinksInHtml(html: string, pathname: string, placement?: string): string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The GYG campaign tagger for one site. `siteKey` is the first cmp segment; scripts/validate-gyg-cmp.mjs enforces
|
|
17
|
+
* the `<siteKey>__<pageType>__<placement>` shape against the built dist.
|
|
18
|
+
*
|
|
19
|
+
* Three deliberate no-ops, each a wrong-attribution bug if dropped:
|
|
20
|
+
* - non-GYG hosts pass through (cmp is a GYG parameter, meaningless elsewhere);
|
|
21
|
+
* - GYG URLs with no partner id pass through: those are editorial citations, not monetized CTAs;
|
|
22
|
+
* - an existing `cmp` is left alone, so tagging is idempotent. That is not an escape hatch for a hand-set
|
|
23
|
+
* campaign value: the gate refuses any other shape.
|
|
24
|
+
*
|
|
25
|
+
* The cmp is appended textually rather than via `searchParams.set` + `toString()`, because that round-trip
|
|
26
|
+
* re-serializes the whole query string (`extra=a%20b` becomes `extra=a+b`).
|
|
27
|
+
*/
|
|
28
|
+
export declare function createGygCmp(siteKey: string): GygCmp;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { pageTypeOf } from "../url/pageType.js";
|
|
2
|
+
const GYG_HOST = /(^|\.)getyourguide\.com$/;
|
|
3
|
+
/** GetYourGuide product id: the `-t<digits>` segment of a tour URL. */
|
|
4
|
+
export function gygProductId(url) {
|
|
5
|
+
return url.match(/-t(\d+)/)?.[1] ?? '';
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* cmp segments are lowercased and non-alphanumerics collapse to a single hyphen.
|
|
9
|
+
*
|
|
10
|
+
* Exported so a component can stamp the SAME normalised string into its data-track-*-placement attributes.
|
|
11
|
+
*/
|
|
12
|
+
export function cmpSegment(raw) {
|
|
13
|
+
return raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The GYG campaign tagger for one site. `siteKey` is the first cmp segment; scripts/validate-gyg-cmp.mjs enforces
|
|
17
|
+
* the `<siteKey>__<pageType>__<placement>` shape against the built dist.
|
|
18
|
+
*
|
|
19
|
+
* Three deliberate no-ops, each a wrong-attribution bug if dropped:
|
|
20
|
+
* - non-GYG hosts pass through (cmp is a GYG parameter, meaningless elsewhere);
|
|
21
|
+
* - GYG URLs with no partner id pass through: those are editorial citations, not monetized CTAs;
|
|
22
|
+
* - an existing `cmp` is left alone, so tagging is idempotent. That is not an escape hatch for a hand-set
|
|
23
|
+
* campaign value: the gate refuses any other shape.
|
|
24
|
+
*
|
|
25
|
+
* The cmp is appended textually rather than via `searchParams.set` + `toString()`, because that round-trip
|
|
26
|
+
* re-serializes the whole query string (`extra=a%20b` becomes `extra=a+b`).
|
|
27
|
+
*/
|
|
28
|
+
export function createGygCmp(siteKey) {
|
|
29
|
+
function withGygCmp(href, pathname, placement) {
|
|
30
|
+
let u;
|
|
31
|
+
try {
|
|
32
|
+
u = new URL(href);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return href;
|
|
36
|
+
}
|
|
37
|
+
if (!GYG_HOST.test(u.hostname.replace(/^www\./, '')))
|
|
38
|
+
return href;
|
|
39
|
+
if (!u.searchParams.has('partner_id'))
|
|
40
|
+
return href;
|
|
41
|
+
if (u.searchParams.has('cmp'))
|
|
42
|
+
return href;
|
|
43
|
+
const type = cmpSegment(pageTypeOf(pathname));
|
|
44
|
+
const place = cmpSegment(placement);
|
|
45
|
+
if (!type || !place)
|
|
46
|
+
return href;
|
|
47
|
+
// cmp is [a-z0-9-] segments joined by `__`, so it needs no percent-encoding.
|
|
48
|
+
const hashAt = href.indexOf('#');
|
|
49
|
+
const base = hashAt === -1 ? href : href.slice(0, hashAt);
|
|
50
|
+
const frag = hashAt === -1 ? '' : href.slice(hashAt);
|
|
51
|
+
return `${base}${base.includes('?') ? '&' : '?'}cmp=${siteKey}__${type}__${place}${frag}`;
|
|
52
|
+
}
|
|
53
|
+
function tagGygLinksInHtml(html, pathname, placement = 'prose') {
|
|
54
|
+
return html.replace(/href="(https?:\/\/[^"]*getyourguide\.com[^"]*)"/g,
|
|
55
|
+
// `&` is the standard encoding inside an attribute; without decoding it first, new URL() reads the key
|
|
56
|
+
// as `amp;partner_id` and the link ships untagged.
|
|
57
|
+
(_m, href) => `href="${withGygCmp(href.replace(/&/g, '&'), pathname, placement).replace(/&(?!amp;|lt;|gt;|quot;|apos;|#\d)/g, '&')}"`);
|
|
58
|
+
}
|
|
59
|
+
return { withGygCmp, tagGygLinksInHtml };
|
|
60
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { gygProductId, cmpSegment, createGygCmp } from './gyg.ts';
|
|
2
|
+
export type { GygCmp } from './gyg.ts';
|
|
3
|
+
export { amazonProductUrl, bookingSearchUrl } from './links.ts';
|
|
4
|
+
export type { BookingSearch } from './links.ts';
|
|
5
|
+
export { travelpayoutsWidgetSrc } from './travelpayouts.ts';
|
|
6
|
+
export type { WidgetLoader, WidgetAccount, WidgetOptions } from './travelpayouts.ts';
|
|
@@ -0,0 +1,17 @@
|
|
|
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 declare function amazonProductUrl(asin: string, tag?: string): string;
|
|
3
|
+
export interface BookingSearch {
|
|
4
|
+
/** Free text. Alone it resolves to a property; with `destId` the destination wins. */
|
|
5
|
+
ss: string;
|
|
6
|
+
destId?: string;
|
|
7
|
+
destType?: string;
|
|
8
|
+
checkin?: string;
|
|
9
|
+
checkout?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A Booking.com search for two adults in one room.
|
|
13
|
+
*
|
|
14
|
+
* Carries no affiliate parameter: Travelpayouts' Emerald Link Switcher rewrites raw booking.com hrefs into tracked
|
|
15
|
+
* redirects at click time. Parameter order is fixed (ss, dest_id, dest_type, checkin, checkout, then the party).
|
|
16
|
+
*/
|
|
17
|
+
export declare function bookingSearchUrl({ ss, destId, destType, checkin, checkout }: BookingSearch): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
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, tag) {
|
|
3
|
+
const base = `https://www.amazon.com/dp/${asin}`;
|
|
4
|
+
return tag === undefined ? base : `${base}/?${new URLSearchParams({ tag })}`;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A Booking.com search for two adults in one room.
|
|
8
|
+
*
|
|
9
|
+
* Carries no affiliate parameter: Travelpayouts' Emerald Link Switcher rewrites raw booking.com hrefs into tracked
|
|
10
|
+
* redirects at click time. Parameter order is fixed (ss, dest_id, dest_type, checkin, checkout, then the party).
|
|
11
|
+
*/
|
|
12
|
+
export function bookingSearchUrl({ ss, destId, destType = 'city', checkin, checkout }) {
|
|
13
|
+
const params = new URLSearchParams({ ss });
|
|
14
|
+
if (destId) {
|
|
15
|
+
params.set('dest_id', destId);
|
|
16
|
+
params.set('dest_type', destType);
|
|
17
|
+
}
|
|
18
|
+
if (checkin)
|
|
19
|
+
params.set('checkin', checkin);
|
|
20
|
+
if (checkout)
|
|
21
|
+
params.set('checkout', checkout);
|
|
22
|
+
params.set('group_adults', '2');
|
|
23
|
+
params.set('group_children', '0');
|
|
24
|
+
params.set('no_rooms', '1');
|
|
25
|
+
params.set('lang', 'en-gb');
|
|
26
|
+
return `https://www.booking.com/searchresults.html?${params.toString()}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
export interface WidgetAccount {
|
|
9
|
+
/** The site's traffic source, which splits revenue per site. */
|
|
10
|
+
trs: string;
|
|
11
|
+
/** The Travelpayouts marker. */
|
|
12
|
+
marker: string;
|
|
13
|
+
}
|
|
14
|
+
export type WidgetOptions = {
|
|
15
|
+
kind: 'hotel';
|
|
16
|
+
direction: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'activity';
|
|
19
|
+
place: string;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'train';
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'car';
|
|
24
|
+
country: string;
|
|
25
|
+
city: string;
|
|
26
|
+
colors: {
|
|
27
|
+
background: string;
|
|
28
|
+
font: string;
|
|
29
|
+
button: string;
|
|
30
|
+
buttonFont: string;
|
|
31
|
+
};
|
|
32
|
+
buttonText?: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* A Travelpayouts widget loader URL. Each kind keeps the parameter order Travelpayouts issued, which differs per kind
|
|
36
|
+
* (hotel puts promo_id before campaign_id, activity and car after).
|
|
37
|
+
*/
|
|
38
|
+
export declare function travelpayoutsWidgetSrc(loader: WidgetLoader, account: WidgetAccount, options: WidgetOptions): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Travelpayouts widget loader URL. Each kind keeps the parameter order Travelpayouts issued, which differs per kind
|
|
3
|
+
* (hotel puts promo_id before campaign_id, activity and car after).
|
|
4
|
+
*/
|
|
5
|
+
export function travelpayoutsWidgetSrc(loader, account, options) {
|
|
6
|
+
const ids = (order) => {
|
|
7
|
+
const promo = ['promo_id', loader.promoId];
|
|
8
|
+
const campaign = loader.campaignId ? [['campaign_id', loader.campaignId]] : [];
|
|
9
|
+
return order === 'promo-first' ? [promo, ...campaign] : [...campaign, promo];
|
|
10
|
+
};
|
|
11
|
+
const who = [['trs', account.trs], ['shmarker', account.marker]];
|
|
12
|
+
let pairs;
|
|
13
|
+
switch (options.kind) {
|
|
14
|
+
case 'hotel':
|
|
15
|
+
pairs = [...who, ['locale', 'en'], ['default_direction', options.direction], ['sustainable', 'false'], ['deals', 'false'],
|
|
16
|
+
['border_radius', '5'], ['plain', 'true'], ['powered_by', 'true'], ...ids('promo-first')];
|
|
17
|
+
break;
|
|
18
|
+
case 'activity':
|
|
19
|
+
pairs = [...who, ['place', options.place], ['items', '3'], ['locale', 'en-US'], ['powered_by', 'true'], ...ids('campaign-first')];
|
|
20
|
+
break;
|
|
21
|
+
case 'train':
|
|
22
|
+
pairs = [['currency', 'USD'], ...who, ['powered_by', 'true'], ['locale', 'en'], ['mode', 'default'], ['theme', 'white'],
|
|
23
|
+
['layout', 'fluid'], ...ids('promo-first')];
|
|
24
|
+
break;
|
|
25
|
+
case 'car':
|
|
26
|
+
pairs = [['currency', 'usd'], ...who, ['country', options.country], ['city', options.city], ['locale', 'en'], ['powered_by', 'true'],
|
|
27
|
+
['bg_color', options.colors.background], ['font_color', options.colors.font], ['button_color', options.colors.button],
|
|
28
|
+
['button_font_color', options.colors.buttonFont], ['button_text', options.buttonText ?? 'Search'], ['rounded_corners', 'true'],
|
|
29
|
+
['benefits', 'true'], ['dc_powered_by', 'false'], ['supplier_logos', 'false'], ...ids('campaign-first')];
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
return `${loader.url}?${pairs.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}`;
|
|
33
|
+
}
|
|
@@ -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;
|