@tokenoftrust/storefront-runner 3.4.0 → 3.5.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/apps/storefront/src/lib/assets/registeredRenditions.ts +86 -0
- package/apps/storefront/src/lib/observability/diag/serverResponseTime.ts +55 -32
- package/apps/storefront/src/middleware/index.ts +12 -1
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/asset-registry.ts +26 -0
- package/packages/public-runtime/src/responsive-renditions.ts +115 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responsive renditions on Worker-rendered pages (preview on both providers, and every page the
|
|
3
|
+
* Worker renders rather than serves from a static bundle). The Worker never encodes: renditions are
|
|
4
|
+
* produced and registered on the node side (the candidate prebuild and every publish,
|
|
5
|
+
* `scripts/publish/lib/hosted-original-publication.mjs`), and a rendered page points at the ones the
|
|
6
|
+
* R2 asset registry (`registry/assets.json` in `STATIC_BUNDLES`) holds, with the same `<picture>` the
|
|
7
|
+
* static bundle carries (`@tot/public-runtime/responsive-renditions`). A source without a complete
|
|
8
|
+
* registered set keeps its original tag.
|
|
9
|
+
*
|
|
10
|
+
* The parsed registry is kept per isolate under the document's ETag; each render pays one `head`
|
|
11
|
+
* while it is unchanged, and none when the page names no pinned raster.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
ASSET_REGISTRY_KEY,
|
|
15
|
+
MAX_ASSET_REGISTRY_BYTES,
|
|
16
|
+
parseAssetRegistry,
|
|
17
|
+
registeredRenditionSet,
|
|
18
|
+
type AssetRegistry,
|
|
19
|
+
} from "@tot/public-runtime/asset-registry";
|
|
20
|
+
import {
|
|
21
|
+
pinnedRasterRef,
|
|
22
|
+
renditionRecipes,
|
|
23
|
+
responsiveImgSrcs,
|
|
24
|
+
rewritePinnedRenditions,
|
|
25
|
+
} from "@tot/public-runtime/responsive-renditions";
|
|
26
|
+
import { R2_ASSET_REGISTRY_STORE } from "@/lib/assets/assetRegistryR2";
|
|
27
|
+
|
|
28
|
+
export interface RenditionRegistryBucket {
|
|
29
|
+
head(key: string): Promise<{ etag: string; size: number } | null>;
|
|
30
|
+
get(key: string): Promise<{ etag: string; size: number; arrayBuffer(): Promise<ArrayBuffer> } | null>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let cached: { etag: string; registry: AssetRegistry } | null = null;
|
|
34
|
+
|
|
35
|
+
/** The R2 asset registry, or null when it is absent, oversized or unreadable. */
|
|
36
|
+
export async function readRenditionRegistry(bucket: RenditionRegistryBucket): Promise<AssetRegistry | null> {
|
|
37
|
+
const head = await bucket.head(ASSET_REGISTRY_KEY);
|
|
38
|
+
if (!head || head.size > MAX_ASSET_REGISTRY_BYTES) return null;
|
|
39
|
+
if (cached?.etag === head.etag) return cached.registry;
|
|
40
|
+
const object = await bucket.get(ASSET_REGISTRY_KEY);
|
|
41
|
+
if (!object || object.size > MAX_ASSET_REGISTRY_BYTES) return null;
|
|
42
|
+
const registry = parseAssetRegistry(new Uint8Array(await object.arrayBuffer()), R2_ASSET_REGISTRY_STORE);
|
|
43
|
+
if (registry) cached = { etag: object.etag, registry };
|
|
44
|
+
return registry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `html` with every pinned raster `<img>` pointed at `tenantId`'s registered renditions. */
|
|
48
|
+
export function applyRegisteredRenditions(html: string, tenantId: string, registry: AssetRegistry): string {
|
|
49
|
+
return rewritePinnedRenditions(html, (entry) =>
|
|
50
|
+
registeredRenditionSet(registry, tenantId, entry, (width) => renditionRecipes(entry, width)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A rendered HTML document with registered renditions applied; any other response unchanged. A
|
|
55
|
+
* static-bundle response is served verbatim (its renditions were decided when it was built), and a
|
|
56
|
+
* registry that cannot be read leaves the page as rendered.
|
|
57
|
+
*/
|
|
58
|
+
export async function withRegisteredRenditions(response: Response, input: {
|
|
59
|
+
tenantId: string | undefined;
|
|
60
|
+
readBucket: () => Promise<RenditionRegistryBucket | null | undefined>;
|
|
61
|
+
}): Promise<Response> {
|
|
62
|
+
if (!input.tenantId || response.status !== 200 || !response.body ||
|
|
63
|
+
!(response.headers.get("content-type") ?? "").includes("text/html") ||
|
|
64
|
+
response.headers.has("x-tot-static-bundle")) return response;
|
|
65
|
+
const html = await response.text();
|
|
66
|
+
const rebuilt = (body: string) => {
|
|
67
|
+
const headers = new Headers(response.headers);
|
|
68
|
+
headers.delete("content-length");
|
|
69
|
+
headers.delete("etag");
|
|
70
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers });
|
|
71
|
+
};
|
|
72
|
+
if (!responsiveImgSrcs(html).some((src) => pinnedRasterRef(src))) return rebuilt(html);
|
|
73
|
+
let registry: AssetRegistry | null = null;
|
|
74
|
+
try {
|
|
75
|
+
const bucket = await input.readBucket();
|
|
76
|
+
registry = bucket ? await readRenditionRegistry(bucket) : null;
|
|
77
|
+
} catch {
|
|
78
|
+
registry = null;
|
|
79
|
+
}
|
|
80
|
+
return rebuilt(registry ? applyRegisteredRenditions(html, input.tenantId, registry) : html);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Test seam: forget the isolate's cached registry. */
|
|
84
|
+
export function resetRenditionRegistryCache(): void {
|
|
85
|
+
cached = null;
|
|
86
|
+
}
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The document's server response time for the evidence panel
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The document's server response time for the evidence panel, measured in the viewer's browser
|
|
3
|
+
* (a Worker fetching its own zone is answered by the origin, never by this Worker, so a
|
|
4
|
+
* server-side self-fetch cannot see the render). The panel script fetches the home document
|
|
5
|
+
* same-origin with the viewer's credentials and `__preview` grant, then reports the `total` the
|
|
6
|
+
* Worker stamps in `Server-Timing` (`lib/observability/shared/serverTiming.ts`) beside the time to
|
|
7
|
+
* first byte. Advisory — never gates promotion.
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* than as a number.
|
|
9
|
+
* A gate page or any non-200 answer is not the render a reviewer gets, so it is reported as not
|
|
10
|
+
* measured rather than as a number.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
export interface ServerResponseTime {
|
|
12
14
|
measured: true;
|
|
13
15
|
/** `total;dur` from the document's `Server-Timing`, or null when the header is absent. */
|
|
14
16
|
serverTotalMs: number | null;
|
|
15
|
-
/**
|
|
17
|
+
/** Time to first byte. */
|
|
16
18
|
ttfbMs: number;
|
|
17
19
|
/** The isolate was serving its first request (`isolate;desc="cold"`). */
|
|
18
20
|
cold: boolean;
|
|
@@ -23,6 +25,8 @@ export interface ServerResponseTimeUnmeasured {
|
|
|
23
25
|
reason: string;
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
export type ServerResponseTimeResult = ServerResponseTime | ServerResponseTimeUnmeasured;
|
|
29
|
+
|
|
26
30
|
/** Pull `total;dur` and the cold marker out of a `Server-Timing` header value. */
|
|
27
31
|
export function parseServerTiming(header: string | null | undefined): Pick<ServerResponseTime, "serverTotalMs" | "cold"> {
|
|
28
32
|
if (!header) return { serverTotalMs: null, cold: false };
|
|
@@ -34,35 +38,54 @@ export function parseServerTiming(header: string | null | undefined): Pick<Serve
|
|
|
34
38
|
};
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const headers = new Headers();
|
|
47
|
-
if (cookie) headers.set("cookie", cookie);
|
|
48
|
-
return { url, headers };
|
|
41
|
+
/** Turn the home document's response into a measurement, or the reason it is not one. */
|
|
42
|
+
export function classifyHomeResponse(
|
|
43
|
+
res: { status: number; headers: { get(name: string): string | null } },
|
|
44
|
+
ttfbMs: number,
|
|
45
|
+
): ServerResponseTimeResult {
|
|
46
|
+
const gate = res.headers.get("x-tot-gate");
|
|
47
|
+
if (gate) return { measured: false, reason: `the fetch was answered by the access gate (${gate}), not the store render` };
|
|
48
|
+
if (res.status !== 200) return { measured: false, reason: `the home document answered ${res.status}, not a render` };
|
|
49
|
+
return { measured: true, ...parseServerTiming(res.headers.get("server-timing")), ttfbMs: Math.round(ttfbMs) };
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/**
|
|
52
|
-
export
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
/** The sentence the panel shows for a result. */
|
|
53
|
+
export function describeServerResponseTime(result: ServerResponseTimeResult): string {
|
|
54
|
+
if (!result.measured) return `Not measured: ${result.reason}.`;
|
|
55
|
+
const server = result.serverTotalMs !== null ? `${result.serverTotalMs} ms server total (Server-Timing)` : "no Server-Timing header";
|
|
56
|
+
return `Home document: ${server} · ${result.ttfbMs} ms to first byte${result.cold ? " · cold isolate" : ""}. A hosted preview is a Worker render; a published static site is served from the CDN and is faster.`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface BrowserDeps {
|
|
60
|
+
fetch: typeof fetch;
|
|
61
|
+
/** Resource-timing entries for a URL (`performance.getEntriesByName`). */
|
|
62
|
+
entries: (url: string) => PerformanceEntry[];
|
|
63
|
+
now: () => number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fetch the home document as the viewer and measure it. TTFB is the resource-timing
|
|
68
|
+
* `responseStart - requestStart` for that request, falling back to the wall time to headers.
|
|
69
|
+
*/
|
|
70
|
+
export async function measureHomeDocument(homeUrl: URL, deps: BrowserDeps): Promise<ServerResponseTimeResult> {
|
|
56
71
|
try {
|
|
57
|
-
const started = now();
|
|
58
|
-
const res = await fetch(
|
|
59
|
-
const
|
|
60
|
-
await res.
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return { measured: true, ...parseServerTiming(res.headers.get("server-timing")), ttfbMs: Math.round(ttfbMs) };
|
|
72
|
+
const started = deps.now();
|
|
73
|
+
const res = await deps.fetch(homeUrl.href, { credentials: "include", redirect: "manual", cache: "no-store" });
|
|
74
|
+
const wallMs = deps.now() - started;
|
|
75
|
+
await res.arrayBuffer();
|
|
76
|
+
const timing = deps.entries(homeUrl.href).at(-1) as PerformanceResourceTiming | undefined;
|
|
77
|
+
const ttfbMs = timing && timing.responseStart > 0 && timing.requestStart > 0 ? timing.responseStart - timing.requestStart : wallMs;
|
|
78
|
+
return classifyHomeResponse(res, ttfbMs);
|
|
65
79
|
} catch {
|
|
66
80
|
return { measured: false, reason: "the home document could not be fetched" };
|
|
67
81
|
}
|
|
68
82
|
}
|
|
83
|
+
|
|
84
|
+
/** Measure the home document and write the sentence into `target`. */
|
|
85
|
+
export async function mountServerResponseTime(target: Element, pageUrl: string, deps: BrowserDeps): Promise<void> {
|
|
86
|
+
const page = new URL(pageUrl);
|
|
87
|
+
const homeUrl = new URL(target.getAttribute("data-home-path") ?? "/", page.origin);
|
|
88
|
+
const grant = page.searchParams.get("__preview");
|
|
89
|
+
if (grant) homeUrl.searchParams.set("__preview", grant);
|
|
90
|
+
target.textContent = describeServerResponseTime(await measureHomeDocument(homeUrl, deps));
|
|
91
|
+
}
|
|
@@ -27,6 +27,8 @@ import { resolveCdnForwardRouting, resolveTenantRouting } from "@/lib/access/sha
|
|
|
27
27
|
import { crossSiteFormRefusal, verifyCdnForward } from "@/lib/publication/shared/cdnForward";
|
|
28
28
|
import { SAMPLE_CART_HANDOFF_URL, compileRouteTierIndexV1, resolvePublicationRoutePlanV1, type RouteTierIndexV1 , storefrontModeRequiresSignIn } from "@tot/public-runtime";
|
|
29
29
|
import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
|
|
30
|
+
import { HTML_CACHE_POLICY_CAPTURE_PROBE } from "@tot/public-runtime";
|
|
31
|
+
import type { RenditionRegistryBucket } from "@/lib/assets/registeredRenditions";
|
|
30
32
|
import { DEV_DRAFT_COOKIE } from "@/lib/developer-experience/dev/devDraft";
|
|
31
33
|
import { applySecurityHeaders } from "@/lib/access/shared/securityHeaders";
|
|
32
34
|
import { visitorRegionFor, withVisitorRegionCookie } from "@/lib/compliance/shared/visitorRegionCookie";
|
|
@@ -80,7 +82,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
80
82
|
const timing = context.locals.serverTiming ?? new ServerTiming();
|
|
81
83
|
context.locals.serverTiming = timing;
|
|
82
84
|
const seen: { cdnForwarded: boolean; assetBasePath?: string } = { cdnForwarded: false };
|
|
83
|
-
const
|
|
85
|
+
const served = await serveRequest(context, next, seen, timing);
|
|
86
|
+
// Registered responsive renditions on a rendered page, by the same addresses and markup the static
|
|
87
|
+
// bundle carries. The materialize render is left to the bundle build, which owns its renditions.
|
|
88
|
+
const { withRegisteredRenditions } = await import("@/lib/assets/registeredRenditions");
|
|
89
|
+
const response = outer && !context.request.headers.get(HTML_CACHE_POLICY_CAPTURE_PROBE)
|
|
90
|
+
? await withRegisteredRenditions(served, {
|
|
91
|
+
tenantId: context.locals.tenant?.tenant_id,
|
|
92
|
+
readBucket: async () => (await readR2("STATIC_BUNDLES")) as unknown as RenditionRegistryBucket | undefined,
|
|
93
|
+
})
|
|
94
|
+
: served;
|
|
84
95
|
const { scopePreviewPinnedImageUrls } = await import("@/lib/assets/preview-pinned-image-urls");
|
|
85
96
|
const scoped = await scopePreviewPinnedImageUrls(response, context.locals.apiBasePath ?? seen.assetBasePath ?? "");
|
|
86
97
|
const finished = withVisitorRegionCookie(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./hosted-image-media-types": "./src/hosted-image-media-types.ts",
|
|
21
21
|
"./durable-assets": "./src/durable-assets.ts",
|
|
22
22
|
"./asset-registry": "./src/asset-registry.ts",
|
|
23
|
+
"./responsive-renditions": "./src/responsive-renditions.ts",
|
|
23
24
|
"./svg-sanitize": "./src/svg-sanitize.ts",
|
|
24
25
|
"./pool-asset-headers": "./src/pool-asset-headers.ts",
|
|
25
26
|
"./catalog-files": "./src/catalog-files.ts",
|
|
@@ -442,6 +442,32 @@ export function registeredRendition(
|
|
|
442
442
|
return null;
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
+
/** A source's complete rendition set: each rendition's width and pool entry, and the source's dimensions. */
|
|
446
|
+
export interface RenditionSet { renditions: { width: number; entry: string }[]; width: number; height: number }
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Every registered rendition of `parent` for the recipes a build makes for a source of its registered
|
|
450
|
+
* width (`recipesFor`), or null unless the registry records the source's dimensions and holds every
|
|
451
|
+
* one of them for `tenantId` ({@link registeredRendition}). A partial set is null: its source is
|
|
452
|
+
* encoded whole, or served as its original.
|
|
453
|
+
*/
|
|
454
|
+
export function registeredRenditionSet(
|
|
455
|
+
registry: AssetRegistry | null | undefined, tenantId: string, parent: string,
|
|
456
|
+
recipesFor: (width: number) => readonly VariantRecipe[],
|
|
457
|
+
): RenditionSet | null {
|
|
458
|
+
const record = registry?.assets[parent];
|
|
459
|
+
if (!record?.width || !record.height) return null;
|
|
460
|
+
const recipes = recipesFor(record.width);
|
|
461
|
+
if (recipes.length === 0) return null;
|
|
462
|
+
const renditions: RenditionSet["renditions"] = [];
|
|
463
|
+
for (const recipe of recipes) {
|
|
464
|
+
const found = recipe.parent === parent ? registeredRendition(registry, tenantId, recipe) : null;
|
|
465
|
+
if (!found) return null;
|
|
466
|
+
renditions.push({ width: recipe.width, entry: found.entry });
|
|
467
|
+
}
|
|
468
|
+
return { renditions, width: record.width, height: record.height };
|
|
469
|
+
}
|
|
470
|
+
|
|
445
471
|
/**
|
|
446
472
|
* The tenants a record lists other than `tenantId`, without naming them: true when the address is
|
|
447
473
|
* registered and this tenant is not among its tenants. Capture uses it to refuse a reference to
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responsive renditions of a tenant's raster images: the recipe every producer makes them with and
|
|
3
|
+
* the markup every renderer points at them with. One module for both, so the static bundle
|
|
4
|
+
* (`scripts/publish/lib/responsive-images.mjs`, which encodes or reuses renditions while it
|
|
5
|
+
* materializes) and the Worker's rendered pages (`apps/storefront/src/lib/assets/registeredRenditions.ts`,
|
|
6
|
+
* which only reads the asset registry) name the same renditions by the same addresses with the same
|
|
7
|
+
* `<picture>` (ADR 0038 §13).
|
|
8
|
+
*
|
|
9
|
+
* A rendition's identity is its source's pool entry plus its recipe; its bytes live at their own
|
|
10
|
+
* address, `/_a/<sha256>.webp`. An `<img>` with renditions is wrapped in
|
|
11
|
+
* `<picture style="display:contents">` with a WebP `<source srcset>`; the `<img>` itself is untouched
|
|
12
|
+
* and stays the fallback. `sizes` comes from the tag's own `sizes`, else `100vw`. A tag that already
|
|
13
|
+
* has a `srcset`, sits inside a `<picture>`, or names a URL with a query or fragment is left alone.
|
|
14
|
+
*
|
|
15
|
+
* Import-free at runtime (type imports only), so plain node runs it from the publish scripts.
|
|
16
|
+
*/
|
|
17
|
+
import type { RenditionSet, VariantRecipe } from "./asset-registry.js";
|
|
18
|
+
|
|
19
|
+
/** Widths a rendition is produced at, ascending; each is used only below the original's own width. */
|
|
20
|
+
export const RESPONSIVE_WIDTHS: readonly number[] = Object.freeze([480, 768, 1080, 1440, 1920, 2560]);
|
|
21
|
+
export const RENDITION_FORMAT = "webp";
|
|
22
|
+
/** Encoder quality for the WebP renditions. */
|
|
23
|
+
export const WEBP_QUALITY = 78;
|
|
24
|
+
/**
|
|
25
|
+
* The encoder every rendition is made with, part of each rendition's identity. It names the sharp
|
|
26
|
+
* release the lockfile pins (a test holds them equal), so an encoder upgrade is a recipe change:
|
|
27
|
+
* the next build mints new renditions beside the old ones.
|
|
28
|
+
*/
|
|
29
|
+
export const RENDITION_ENCODER = "sharp-0.34.5";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The widths to emit for an original `width` px wide: every standard width below it, plus the
|
|
33
|
+
* original width itself (never upscaled).
|
|
34
|
+
*/
|
|
35
|
+
export function responsiveWidths(width: number): number[] {
|
|
36
|
+
if (!Number.isInteger(width) || width <= 0) return [];
|
|
37
|
+
return [...RESPONSIVE_WIDTHS.filter((w) => w < width), width];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The recipe of `parent`'s rendition at `width`; `parent` is the source's pool entry, `<sha256><ext>`. */
|
|
41
|
+
export function renditionRecipe(parent: string, width: number): VariantRecipe {
|
|
42
|
+
return { kind: "variant", parent, width, format: RENDITION_FORMAT, quality: WEBP_QUALITY, encoder: RENDITION_ENCODER };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every recipe a build makes for `parent` when the source is `width` px wide. */
|
|
46
|
+
export function renditionRecipes(parent: string, width: number): VariantRecipe[] {
|
|
47
|
+
return responsiveWidths(width).map((w) => renditionRecipe(parent, w));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PICTURE_OR_IMG_RE = /<picture\b[\s\S]*?<\/picture\s*>|<img\b[^>]*>/gi;
|
|
51
|
+
const PINNED_RASTER_RE = /^((?:\/[A-Za-z0-9._~%-]+)*)\/_a\/([0-9a-f]{64}\.(?:jpe?g|png|webp))$/i;
|
|
52
|
+
|
|
53
|
+
/** The value of the double-quoted attribute `name` on `tag`, or undefined. */
|
|
54
|
+
export function imgAttr(tag: string, name: string): string | undefined {
|
|
55
|
+
return new RegExp(`\\s${name}="([^"]*)"`, "i").exec(tag)?.[1];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The `src` of an `<img>` tag renditions may be added to, or null. */
|
|
59
|
+
function eligibleSrc(tag: string): string | null {
|
|
60
|
+
if (imgAttr(tag, "srcset") !== undefined) return null;
|
|
61
|
+
const src = imgAttr(tag, "src")?.trim();
|
|
62
|
+
return src && !/[?#]/.test(src) ? src : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `src` of every `<img>` in `html` renditions may be added to: no srcset, outside a `<picture>`. */
|
|
66
|
+
export function responsiveImgSrcs(html: string): string[] {
|
|
67
|
+
const out: string[] = [];
|
|
68
|
+
for (const [match] of html.matchAll(PICTURE_OR_IMG_RE)) {
|
|
69
|
+
if (/^<picture/i.test(match)) continue;
|
|
70
|
+
const src = eligibleSrc(match);
|
|
71
|
+
if (src) out.push(src);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A pinned pool raster reference: its pool entry and the path prefix it is served under (`""` at the
|
|
78
|
+
* root, or a path-routed view's base, e.g. `/<domain>` on a shared preview host).
|
|
79
|
+
*/
|
|
80
|
+
export function pinnedRasterRef(src: string): { prefix: string; entry: string } | null {
|
|
81
|
+
const match = PINNED_RASTER_RE.exec(src);
|
|
82
|
+
return match ? { prefix: match[1]!, entry: match[2]! } : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Wrap every eligible `<img>` whose `src` has a rendition set in a `<picture>` with a WebP `<source>`.
|
|
87
|
+
* `setFor` returns the set and the prefix each rendition address is served under.
|
|
88
|
+
*/
|
|
89
|
+
export function rewriteResponsiveImgTags(
|
|
90
|
+
html: string,
|
|
91
|
+
setFor: (src: string) => { set: RenditionSet; prefix: string } | null | undefined,
|
|
92
|
+
): string {
|
|
93
|
+
return html.replace(PICTURE_OR_IMG_RE, (match) => {
|
|
94
|
+
if (/^<picture/i.test(match)) return match;
|
|
95
|
+
const src = eligibleSrc(match);
|
|
96
|
+
const found = src ? setFor(src) : undefined;
|
|
97
|
+
if (!found?.set.renditions.length) return match;
|
|
98
|
+
const srcset = found.set.renditions.map(({ width, entry }) => `${found.prefix}/_a/${entry} ${width}w`).join(", ");
|
|
99
|
+
const sizes = imgAttr(match, "sizes") ?? "100vw";
|
|
100
|
+
return `<picture style="display:contents"><source type="image/webp" srcset="${srcset}" sizes="${sizes}">${match}</picture>`;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Point every `<img>` that names a pinned pool raster at that source's registered renditions.
|
|
106
|
+
* `setForEntry` is the registry lookup for a source's pool entry; a source with no complete set keeps
|
|
107
|
+
* its original tag, so a missing rendition never puts an unserved address on the page.
|
|
108
|
+
*/
|
|
109
|
+
export function rewritePinnedRenditions(html: string, setForEntry: (entry: string) => RenditionSet | null): string {
|
|
110
|
+
return rewriteResponsiveImgTags(html, (src) => {
|
|
111
|
+
const ref = pinnedRasterRef(src);
|
|
112
|
+
const set = ref ? setForEntry(ref.entry) : null;
|
|
113
|
+
return ref && set ? { set, prefix: ref.prefix } : null;
|
|
114
|
+
});
|
|
115
|
+
}
|