@capacms/sdk 1.0.0-next.0 → 1.0.0-next.10
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 +450 -0
- package/README.md +1754 -156
- package/bin/capa-codegen.js +192 -5
- package/bin/capa.js +235 -0
- package/bin/graphql-project.js +142 -0
- package/bin/project-env.js +58 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +17 -0
- package/dist/codegen.d.ts +55 -0
- package/dist/codegen.js +320 -39
- package/dist/config.d.ts +5 -36
- package/dist/config.js +47 -1
- package/dist/esm/image/index.d.ts +120 -0
- package/dist/esm/image/index.js +250 -0
- package/dist/esm/image/shared-params.generated.d.ts +190 -0
- package/dist/esm/image/shared-params.generated.js +461 -0
- package/dist/esm/nextjs/image-loader.d.ts +60 -0
- package/dist/esm/nextjs/image-loader.js +67 -0
- package/dist/esm/nextjs/overlay.d.ts +30 -0
- package/dist/esm/nextjs/overlay.js +75 -0
- package/dist/esm/overlay/index.d.ts +32 -0
- package/dist/esm/overlay/index.js +576 -0
- package/dist/esm/overlay/protocol.d.ts +187 -0
- package/dist/esm/overlay/protocol.js +240 -0
- package/dist/esm/package.json +4 -0
- package/dist/graphql-codegen.d.ts +117 -0
- package/dist/graphql-codegen.js +705 -0
- package/dist/http.js +1 -1
- package/dist/image/index.d.ts +120 -0
- package/dist/image/index.js +257 -0
- package/dist/image/shared-params.generated.d.ts +190 -0
- package/dist/image/shared-params.generated.js +471 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/next/attrs.d.ts +98 -0
- package/dist/next/attrs.js +125 -0
- package/dist/next/client.d.ts +176 -32
- package/dist/next/client.js +212 -90
- package/dist/next/entry-fields.d.ts +162 -0
- package/dist/next/entry-fields.js +2 -0
- package/dist/next/errors.d.ts +136 -0
- package/dist/next/errors.js +214 -0
- package/dist/next/field-names.d.ts +37 -0
- package/dist/next/field-names.js +145 -0
- package/dist/next/graphql/build.d.ts +27 -0
- package/dist/next/graphql/build.js +98 -0
- package/dist/next/graphql/documents.d.ts +67 -0
- package/dist/next/graphql/documents.js +35 -0
- package/dist/next/graphql/edit-mode.d.ts +16 -0
- package/dist/next/graphql/edit-mode.js +93 -0
- package/dist/next/graphql/filter-values.d.ts +34 -0
- package/dist/next/graphql/filter-values.js +96 -0
- package/dist/next/graphql/introspection.d.ts +89 -0
- package/dist/next/graphql/introspection.js +102 -0
- package/dist/next/graphql/plan.d.ts +115 -0
- package/dist/next/graphql/plan.js +531 -0
- package/dist/next/graphql/request.d.ts +228 -0
- package/dist/next/graphql/request.js +283 -0
- package/dist/next/graphql/rest.d.ts +66 -0
- package/dist/next/graphql/rest.js +502 -0
- package/dist/next/graphql/selection.d.ts +55 -0
- package/dist/next/graphql/selection.js +212 -0
- package/dist/next/graphql/sha256.d.ts +13 -0
- package/dist/next/graphql/sha256.js +86 -0
- package/dist/next/graphql/summary.d.ts +83 -0
- package/dist/next/graphql/summary.js +151 -0
- package/dist/next/graphql/tree-layout.d.ts +36 -0
- package/dist/next/graphql/tree-layout.js +20 -0
- package/dist/next/graphql/tree.d.ts +171 -0
- package/dist/next/graphql/tree.js +249 -0
- package/dist/next/graphql/typed.d.ts +261 -0
- package/dist/next/graphql/typed.js +146 -0
- package/dist/next/index.d.ts +30 -3
- package/dist/next/index.js +34 -1
- package/dist/next/inflate.d.ts +51 -0
- package/dist/next/inflate.js +243 -0
- package/dist/next/key-family.d.ts +31 -0
- package/dist/next/key-family.js +66 -0
- package/dist/next/select-types.d.ts +58 -5
- package/dist/next/system-keys.d.ts +27 -0
- package/dist/next/system-keys.js +42 -0
- package/dist/nextjs/image-loader.d.ts +60 -0
- package/dist/nextjs/image-loader.js +71 -0
- package/dist/nextjs/index.d.ts +484 -5
- package/dist/nextjs/index.js +704 -9
- package/dist/nextjs/overlay.d.ts +30 -0
- package/dist/nextjs/overlay.js +78 -0
- package/dist/overlay/index.d.ts +32 -0
- package/dist/overlay/index.js +596 -0
- package/dist/overlay/protocol.d.ts +187 -0
- package/dist/overlay/protocol.js +253 -0
- package/package.json +70 -15
package/dist/http.js
CHANGED
|
@@ -10,7 +10,7 @@ class CapaError extends Error {
|
|
|
10
10
|
status;
|
|
11
11
|
path;
|
|
12
12
|
constructor(status, path, body) {
|
|
13
|
-
super(`Capa API ${status} on ${path}${body ?
|
|
13
|
+
super(`Capa API ${status} on ${path}${body ? `: ${body.slice(0, 200)}` : ""}`);
|
|
14
14
|
this.name = "CapaError";
|
|
15
15
|
this.status = status;
|
|
16
16
|
this.path = path;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@capacms/sdk/image`: URLs for Capa's image CDN, for any framework.
|
|
3
|
+
*
|
|
4
|
+
* Capa serves every upload at `https://cdn.capacms.com/files/<key>` and
|
|
5
|
+
* resizes it by query: `width`, `height`, `fit`, `quality`, `dpr`, `blur` and
|
|
6
|
+
* `format`. The API answers a URL whose query is spelled any other way than
|
|
7
|
+
* its canonical spelling with a 301 the edge keeps for a year, so every URL
|
|
8
|
+
* built here is already canonical. The spelling is not decided here: it comes
|
|
9
|
+
* from the rules the API serves by (`canonicalQuery` and `parseImageParams`,
|
|
10
|
+
* compiled in from @capa/shared at build time), so the two cannot drift.
|
|
11
|
+
*
|
|
12
|
+
* Three rules beside the API's:
|
|
13
|
+
* - Nothing is enlarged. When the image's own width is known (the `/api/`
|
|
14
|
+
* media shape carries it), a request for more pixels than it has is brought
|
|
15
|
+
* down to its size, keeping the requested shape.
|
|
16
|
+
* - `format=auto` is only sent when asked for.
|
|
17
|
+
* - A URL that is not a Capa image comes back unchanged.
|
|
18
|
+
*
|
|
19
|
+
* A stored URL on `api.capacms.com/files/` (older uploads) names the same file
|
|
20
|
+
* as the CDN: the CDN fetches from that host. Built URLs go to the CDN host,
|
|
21
|
+
* where a resized copy is cached at the edge rather than made again by the
|
|
22
|
+
* origin on every request.
|
|
23
|
+
*/
|
|
24
|
+
import { MAX_OUTPUT_EDGE, type ImageFit, type ImageFormat } from "./shared-params.generated.js";
|
|
25
|
+
export type { ImageFit, ImageFormat };
|
|
26
|
+
export { MAX_OUTPUT_EDGE };
|
|
27
|
+
/** Where Capa serves files. A media `url` from the API starts with this and `/files/`. */
|
|
28
|
+
export declare const CAPA_CDN_ORIGIN = "https://cdn.capacms.com";
|
|
29
|
+
/**
|
|
30
|
+
* True when `src` is a full URL of a file on Capa: `https://cdn.capacms.com/files/...`
|
|
31
|
+
* or `https://api.capacms.com/files/...`. A key, a path or another host is not.
|
|
32
|
+
* Use it to mark the other images `unoptimized` in next/image.
|
|
33
|
+
*/
|
|
34
|
+
export declare function isCapaImageUrl(src: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* A media value: the `/api/` shape `{ id, url, alt, type, width, height }`, the
|
|
37
|
+
* legacy client's `CapaImage`, or anything else with a `url`. `width` and
|
|
38
|
+
* `height` are the image's own pixel size, used so it is never enlarged.
|
|
39
|
+
*/
|
|
40
|
+
export interface ImageLike {
|
|
41
|
+
url: string | null;
|
|
42
|
+
width?: number | null;
|
|
43
|
+
height?: number | null;
|
|
44
|
+
/** The file's kind. Anything but `image` (or a missing kind) is returned unchanged. */
|
|
45
|
+
type?: string | null;
|
|
46
|
+
}
|
|
47
|
+
/** A media value, a full CDN URL, or a file key (`sunns_1790971326210_d54fed020806.jpg`). */
|
|
48
|
+
export type ImageSource = string | ImageLike;
|
|
49
|
+
/** What Capa's CDN can do to an image. Every one is optional, and a default is left out of the URL. */
|
|
50
|
+
export interface ImageOptions {
|
|
51
|
+
/** Width in CSS pixels; with `dpr`, the image is `width × dpr` pixels wide. */
|
|
52
|
+
width?: number | null;
|
|
53
|
+
/** Height in CSS pixels. */
|
|
54
|
+
height?: number | null;
|
|
55
|
+
/** How the image meets a box given by both `width` and `height`: `inside` (the default), `cover` (crop) or `contain` (pad). */
|
|
56
|
+
fit?: ImageFit;
|
|
57
|
+
/** 1 to 100; Capa's default is 80. Ignored for png. */
|
|
58
|
+
quality?: number;
|
|
59
|
+
/** Device pixel ratio, 1 to 3. */
|
|
60
|
+
dpr?: number;
|
|
61
|
+
/** A blurred placeholder `blur × blur` pixels square, 1 to 256. */
|
|
62
|
+
blur?: number;
|
|
63
|
+
/**
|
|
64
|
+
* The format to encode. Left out, the image keeps its own format. `auto`
|
|
65
|
+
* picks AVIF or WebP from the browser's `Accept` header; through the CDN it
|
|
66
|
+
* currently returns JPEG, so use `webp` until that is fixed.
|
|
67
|
+
*/
|
|
68
|
+
format?: ImageFormat;
|
|
69
|
+
}
|
|
70
|
+
/** The options `srcSet` takes: each candidate's width comes from its list, and its height from `aspectRatio`. */
|
|
71
|
+
export interface SrcSetOptions {
|
|
72
|
+
/** Width over height. Given, each candidate is `width` by `width / aspectRatio`; use it with `fit: "cover"` to crop. */
|
|
73
|
+
aspectRatio?: number;
|
|
74
|
+
fit?: ImageFit;
|
|
75
|
+
quality?: number;
|
|
76
|
+
format?: ImageFormat;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The URL of a Capa image, resized as `options` say, spelled exactly as Capa's
|
|
80
|
+
* CDN serves it without a redirect.
|
|
81
|
+
*
|
|
82
|
+
* `image` is a media value (`entry.fields.hero`), a CDN URL, or a file key.
|
|
83
|
+
* Gives `undefined` when there is no image (`null`, or a media value whose
|
|
84
|
+
* `url` is null or empty, which is how the legacy API sends an unset image),
|
|
85
|
+
* so a media value always gives `string | undefined`; a string always gives a
|
|
86
|
+
* string. Gives back unchanged a URL that is not a Capa image (another host, a
|
|
87
|
+
* site path) or a media value that is not an image. Throws a `TypeError` for
|
|
88
|
+
* an option Capa would refuse with a 400.
|
|
89
|
+
*
|
|
90
|
+
* ```ts
|
|
91
|
+
* imageUrl(entry.fields.hero, { width: 1200, format: "webp" })
|
|
92
|
+
* // https://cdn.capacms.com/files/<key>?format=webp&width=1200
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export declare function imageUrl(image: string, options?: ImageOptions): string;
|
|
96
|
+
export declare function imageUrl(image: ImageSource | null | undefined, options?: ImageOptions): string | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* A `srcset` for a Capa image: one candidate per width, smallest first, each
|
|
99
|
+
* as `<url> <width>w`. Widths past the image's own width (when known) are
|
|
100
|
+
* dropped in favour of one candidate at its own width.
|
|
101
|
+
*
|
|
102
|
+
* Gives `undefined` when there is nothing to resize: no image, or one that is
|
|
103
|
+
* not Capa's. Throws a `TypeError` for a width list that is empty or not whole
|
|
104
|
+
* pixels, or an option Capa would refuse.
|
|
105
|
+
*
|
|
106
|
+
* ```ts
|
|
107
|
+
* srcSet(entry.fields.hero, [640, 960, 1280, 1920], { format: "webp" })
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export declare function srcSet(image: ImageSource | null | undefined, widths: readonly number[], options?: SrcSetOptions): string | undefined;
|
|
111
|
+
/**
|
|
112
|
+
* A `sizes` attribute from min-width breakpoints, widest first so the first
|
|
113
|
+
* match is the right one, then the fallback.
|
|
114
|
+
*
|
|
115
|
+
* ```ts
|
|
116
|
+
* sizes({ 768: "50vw", 1280: "33vw" })
|
|
117
|
+
* // (min-width: 1280px) 33vw, (min-width: 768px) 50vw, 100vw
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
export declare function sizes(breakpoints: Readonly<Record<number, string>>, fallback?: string): string;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CAPA_CDN_ORIGIN = exports.MAX_OUTPUT_EDGE = void 0;
|
|
4
|
+
exports.isCapaImageUrl = isCapaImageUrl;
|
|
5
|
+
exports.imageUrl = imageUrl;
|
|
6
|
+
exports.srcSet = srcSet;
|
|
7
|
+
exports.sizes = sizes;
|
|
8
|
+
/**
|
|
9
|
+
* `@capacms/sdk/image`: URLs for Capa's image CDN, for any framework.
|
|
10
|
+
*
|
|
11
|
+
* Capa serves every upload at `https://cdn.capacms.com/files/<key>` and
|
|
12
|
+
* resizes it by query: `width`, `height`, `fit`, `quality`, `dpr`, `blur` and
|
|
13
|
+
* `format`. The API answers a URL whose query is spelled any other way than
|
|
14
|
+
* its canonical spelling with a 301 the edge keeps for a year, so every URL
|
|
15
|
+
* built here is already canonical. The spelling is not decided here: it comes
|
|
16
|
+
* from the rules the API serves by (`canonicalQuery` and `parseImageParams`,
|
|
17
|
+
* compiled in from @capa/shared at build time), so the two cannot drift.
|
|
18
|
+
*
|
|
19
|
+
* Three rules beside the API's:
|
|
20
|
+
* - Nothing is enlarged. When the image's own width is known (the `/api/`
|
|
21
|
+
* media shape carries it), a request for more pixels than it has is brought
|
|
22
|
+
* down to its size, keeping the requested shape.
|
|
23
|
+
* - `format=auto` is only sent when asked for.
|
|
24
|
+
* - A URL that is not a Capa image comes back unchanged.
|
|
25
|
+
*
|
|
26
|
+
* A stored URL on `api.capacms.com/files/` (older uploads) names the same file
|
|
27
|
+
* as the CDN: the CDN fetches from that host. Built URLs go to the CDN host,
|
|
28
|
+
* where a resized copy is cached at the edge rather than made again by the
|
|
29
|
+
* origin on every request.
|
|
30
|
+
*/
|
|
31
|
+
const shared_params_generated_js_1 = require("./shared-params.generated.js");
|
|
32
|
+
Object.defineProperty(exports, "MAX_OUTPUT_EDGE", { enumerable: true, get: function () { return shared_params_generated_js_1.MAX_OUTPUT_EDGE; } });
|
|
33
|
+
/** Where Capa serves files. A media `url` from the API starts with this and `/files/`. */
|
|
34
|
+
exports.CAPA_CDN_ORIGIN = "https://cdn.capacms.com";
|
|
35
|
+
/**
|
|
36
|
+
* The API's own host, which some stored URLs name (`https://api.capacms.com/files/<key>`).
|
|
37
|
+
* It is the CDN's origin, with nothing in front of it, so the same key is the
|
|
38
|
+
* same file on both; a URL built here always names the CDN.
|
|
39
|
+
*/
|
|
40
|
+
const CAPA_API_ORIGIN = "https://api.capacms.com";
|
|
41
|
+
const CAPA_ORIGINS = new Set([exports.CAPA_CDN_ORIGIN, CAPA_API_ORIGIN]);
|
|
42
|
+
const FILES_PATH = "/files/";
|
|
43
|
+
/** A full `https://` URL of a file on Capa's CDN or API host, as a URL, or null. */
|
|
44
|
+
function capaFileUrl(text) {
|
|
45
|
+
let url;
|
|
46
|
+
try {
|
|
47
|
+
url = new URL(text);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (!CAPA_ORIGINS.has(url.origin) || !url.pathname.startsWith(FILES_PATH) || url.pathname.length === FILES_PATH.length)
|
|
53
|
+
return null;
|
|
54
|
+
// The same path, query and fragment on the CDN host.
|
|
55
|
+
return url.origin === exports.CAPA_CDN_ORIGIN ? url : new URL(`${url.pathname}${url.search}${url.hash}`, exports.CAPA_CDN_ORIGIN);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* True when `src` is a full URL of a file on Capa: `https://cdn.capacms.com/files/...`
|
|
59
|
+
* or `https://api.capacms.com/files/...`. A key, a path or another host is not.
|
|
60
|
+
* Use it to mark the other images `unoptimized` in next/image.
|
|
61
|
+
*/
|
|
62
|
+
function isCapaImageUrl(src) {
|
|
63
|
+
return typeof src === "string" && /^https:\/\//i.test(src) && capaFileUrl(src) !== null;
|
|
64
|
+
}
|
|
65
|
+
const OPTION_KEYS = ["width", "height", "fit", "quality", "dpr", "blur", "format"];
|
|
66
|
+
function positive(value) {
|
|
67
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
68
|
+
}
|
|
69
|
+
/** True when `text` names a scheme (`https:`, `data:`), so it is a URL rather than a file key. */
|
|
70
|
+
function hasScheme(text) {
|
|
71
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(text);
|
|
72
|
+
}
|
|
73
|
+
/** A string or `url` as a Capa image, or why not. */
|
|
74
|
+
function resolveUrl(text, image) {
|
|
75
|
+
if (hasScheme(text)) {
|
|
76
|
+
const url = capaFileUrl(text);
|
|
77
|
+
if (url === null)
|
|
78
|
+
return { unchanged: text };
|
|
79
|
+
return { target: { url, intrinsicWidth: positive(image?.width), intrinsicHeight: positive(image?.height) } };
|
|
80
|
+
}
|
|
81
|
+
// A path (`/logo.png`, `//host/x`) is the site's own or another host's.
|
|
82
|
+
if (text === "" || text.startsWith("/"))
|
|
83
|
+
return { unchanged: text };
|
|
84
|
+
// A file key, which may hold folders: each segment is encoded on its own.
|
|
85
|
+
const path = text.split("/").map(encodeURIComponent).join("/");
|
|
86
|
+
return { target: { url: new URL(`${exports.CAPA_CDN_ORIGIN}${FILES_PATH}${path}`), intrinsicWidth: positive(image?.width), intrinsicHeight: positive(image?.height) } };
|
|
87
|
+
}
|
|
88
|
+
function resolve(image) {
|
|
89
|
+
if (image === null || image === undefined)
|
|
90
|
+
return null;
|
|
91
|
+
if (typeof image === "string")
|
|
92
|
+
return resolveUrl(image);
|
|
93
|
+
if (typeof image !== "object" || typeof image.url !== "string" || image.url === "")
|
|
94
|
+
return null;
|
|
95
|
+
if (typeof image.type === "string" && image.type !== "image" && !image.type.startsWith("image/"))
|
|
96
|
+
return { unchanged: image.url };
|
|
97
|
+
return resolveUrl(image.url, image);
|
|
98
|
+
}
|
|
99
|
+
/** A number from a query value, when it is one. */
|
|
100
|
+
function numeric(value) {
|
|
101
|
+
if (typeof value !== "string" || !/^\d+(\.\d+)?$/.test(value.trim()))
|
|
102
|
+
return undefined;
|
|
103
|
+
return positive(Number(value));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Brings a request that would enlarge the image down to the image's own size.
|
|
107
|
+
*
|
|
108
|
+
* The scale the request puts on the image's content is worked out for its
|
|
109
|
+
* fit. `inside` and `contain` fit the image within the box, so the smaller of
|
|
110
|
+
* the two ratios rules and both sizes must be known; `cover` fills the box, so
|
|
111
|
+
* the larger rules, and with only the width known the width's ratio is a floor
|
|
112
|
+
* on it. Above 1, the box is divided by that scale, which keeps its shape and
|
|
113
|
+
* makes the content its own size. Nothing changes when the scale cannot be
|
|
114
|
+
* worked out. Blur is left alone: its size comes from `blur`, not the box.
|
|
115
|
+
*/
|
|
116
|
+
function capToImage(query, target) {
|
|
117
|
+
const W = target.intrinsicWidth;
|
|
118
|
+
const H = target.intrinsicHeight;
|
|
119
|
+
if ((W === undefined && H === undefined) || query.blur !== undefined)
|
|
120
|
+
return;
|
|
121
|
+
const width = numeric(query.width);
|
|
122
|
+
const height = numeric(query.height);
|
|
123
|
+
const dpr = numeric(query.dpr) ?? 1;
|
|
124
|
+
const fit = typeof query.fit === "string" ? query.fit.trim().toLowerCase() : "inside";
|
|
125
|
+
const sw = width !== undefined && W !== undefined ? (width * dpr) / W : undefined;
|
|
126
|
+
const sh = height !== undefined && H !== undefined ? (height * dpr) / H : undefined;
|
|
127
|
+
let scale;
|
|
128
|
+
if (width !== undefined && height === undefined)
|
|
129
|
+
scale = sw;
|
|
130
|
+
else if (height !== undefined && width === undefined)
|
|
131
|
+
scale = sh;
|
|
132
|
+
else if (width !== undefined && height !== undefined) {
|
|
133
|
+
if (fit === "cover")
|
|
134
|
+
scale = sh === undefined ? sw : sw === undefined ? sh : Math.max(sw, sh);
|
|
135
|
+
else
|
|
136
|
+
scale = sw === undefined || sh === undefined ? undefined : Math.min(sw, sh);
|
|
137
|
+
}
|
|
138
|
+
if (scale === undefined || scale <= 1)
|
|
139
|
+
return;
|
|
140
|
+
// The epsilon keeps a division that is exact on paper (1600 / (4/3)) from flooring to one less.
|
|
141
|
+
const down = (n) => String(Math.max(1, Math.floor(n / scale + 1e-9)));
|
|
142
|
+
if (width !== undefined)
|
|
143
|
+
query.width = down(width);
|
|
144
|
+
if (height !== undefined)
|
|
145
|
+
query.height = down(height);
|
|
146
|
+
}
|
|
147
|
+
function fail(fn, message) {
|
|
148
|
+
throw new TypeError(`@capacms/sdk: ${fn}: ${message}`);
|
|
149
|
+
}
|
|
150
|
+
/** The canonical URL for a Capa image, and the pixel width it asks for. */
|
|
151
|
+
function build(fn, target, options) {
|
|
152
|
+
const { url } = target;
|
|
153
|
+
const search = url.search.slice(1);
|
|
154
|
+
// The URL's own image parameters, as Fastify would hand them to the API.
|
|
155
|
+
const query = {};
|
|
156
|
+
for (const [key, value] of new URLSearchParams(search)) {
|
|
157
|
+
if (!shared_params_generated_js_1.LEGACY_PARAMS.has(key) && !shared_params_generated_js_1.NEW_PARAMS.has(key))
|
|
158
|
+
continue;
|
|
159
|
+
const prior = query[key];
|
|
160
|
+
query[key] = prior === undefined ? value : [].concat(prior, value);
|
|
161
|
+
}
|
|
162
|
+
// The options win over them.
|
|
163
|
+
for (const key of OPTION_KEYS) {
|
|
164
|
+
const value = options[key];
|
|
165
|
+
if (value !== undefined && value !== null)
|
|
166
|
+
query[key] = String(value);
|
|
167
|
+
}
|
|
168
|
+
capToImage(query, target);
|
|
169
|
+
const parsed = (0, shared_params_generated_js_1.parseImageParams)(query);
|
|
170
|
+
if (!parsed.ok)
|
|
171
|
+
fail(fn, parsed.rejection.error);
|
|
172
|
+
const canonical = (0, shared_params_generated_js_1.canonicalQuery)(search, parsed.params);
|
|
173
|
+
const href = `${url.origin}${url.pathname}${canonical ? `?${canonical}` : ""}${url.hash}`;
|
|
174
|
+
return { href, width: parsed.params.width };
|
|
175
|
+
}
|
|
176
|
+
function imageUrl(image, options = {}) {
|
|
177
|
+
const resolved = resolve(image);
|
|
178
|
+
if (resolved === null)
|
|
179
|
+
return undefined;
|
|
180
|
+
if ("unchanged" in resolved)
|
|
181
|
+
return resolved.unchanged;
|
|
182
|
+
return build("imageUrl", resolved.target, options).href;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* A `srcset` for a Capa image: one candidate per width, smallest first, each
|
|
186
|
+
* as `<url> <width>w`. Widths past the image's own width (when known) are
|
|
187
|
+
* dropped in favour of one candidate at its own width.
|
|
188
|
+
*
|
|
189
|
+
* Gives `undefined` when there is nothing to resize: no image, or one that is
|
|
190
|
+
* not Capa's. Throws a `TypeError` for a width list that is empty or not whole
|
|
191
|
+
* pixels, or an option Capa would refuse.
|
|
192
|
+
*
|
|
193
|
+
* ```ts
|
|
194
|
+
* srcSet(entry.fields.hero, [640, 960, 1280, 1920], { format: "webp" })
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
function srcSet(image, widths, options = {}) {
|
|
198
|
+
if (!Array.isArray(widths) || widths.length === 0 || !widths.every((w) => Number.isSafeInteger(w) && w > 0)) {
|
|
199
|
+
fail("srcSet", `widths must be a list of whole pixel widths, such as [640, 1280]. Got ${JSON.stringify(widths)}.`);
|
|
200
|
+
}
|
|
201
|
+
const loose = options;
|
|
202
|
+
if (loose.width !== undefined)
|
|
203
|
+
fail("srcSet", "srcSet takes its widths as the second argument, not a `width` option.");
|
|
204
|
+
if (loose.height !== undefined)
|
|
205
|
+
fail("srcSet", "give `aspectRatio` (width / height) rather than `height`; each candidate's height follows its width.");
|
|
206
|
+
if (loose.dpr !== undefined || loose.blur !== undefined) {
|
|
207
|
+
fail("srcSet", "`dpr` and `blur` do not apply to a srcset: the browser picks a candidate by its w descriptor.");
|
|
208
|
+
}
|
|
209
|
+
const { aspectRatio } = options;
|
|
210
|
+
if (aspectRatio !== undefined && positive(aspectRatio) === undefined) {
|
|
211
|
+
fail("srcSet", `\`aspectRatio\` must be a positive number (width / height). Got ${JSON.stringify(aspectRatio)}.`);
|
|
212
|
+
}
|
|
213
|
+
const resolved = resolve(image);
|
|
214
|
+
if (resolved === null || "unchanged" in resolved)
|
|
215
|
+
return undefined;
|
|
216
|
+
const seen = new Set();
|
|
217
|
+
const candidates = [];
|
|
218
|
+
for (const width of [...new Set(widths)].sort((a, b) => a - b)) {
|
|
219
|
+
const height = aspectRatio === undefined ? undefined : Math.max(1, Math.round(width / aspectRatio));
|
|
220
|
+
const { href, width: built } = build("srcSet", resolved.target, {
|
|
221
|
+
width,
|
|
222
|
+
height,
|
|
223
|
+
fit: options.fit,
|
|
224
|
+
quality: options.quality,
|
|
225
|
+
format: options.format,
|
|
226
|
+
});
|
|
227
|
+
if (seen.has(href))
|
|
228
|
+
continue;
|
|
229
|
+
seen.add(href);
|
|
230
|
+
candidates.push(`${href} ${built ?? width}w`);
|
|
231
|
+
}
|
|
232
|
+
return candidates.join(", ");
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* A `sizes` attribute from min-width breakpoints, widest first so the first
|
|
236
|
+
* match is the right one, then the fallback.
|
|
237
|
+
*
|
|
238
|
+
* ```ts
|
|
239
|
+
* sizes({ 768: "50vw", 1280: "33vw" })
|
|
240
|
+
* // (min-width: 1280px) 33vw, (min-width: 768px) 50vw, 100vw
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
function sizes(breakpoints, fallback = "100vw") {
|
|
244
|
+
const rules = [];
|
|
245
|
+
for (const [key, size] of Object.entries(breakpoints)) {
|
|
246
|
+
const px = Number(key);
|
|
247
|
+
if (!/^\d+$/.test(key) || px <= 0)
|
|
248
|
+
fail("sizes", `breakpoints are min-widths in pixels, such as { 768: "50vw" }. Got ${JSON.stringify(key)}.`);
|
|
249
|
+
if (typeof size !== "string" || size.trim() === "")
|
|
250
|
+
fail("sizes", `the size for ${key}px must be a length, such as "50vw". Got ${JSON.stringify(size)}.`);
|
|
251
|
+
rules.push([px, size.trim()]);
|
|
252
|
+
}
|
|
253
|
+
if (typeof fallback !== "string" || fallback.trim() === "")
|
|
254
|
+
fail("sizes", `the fallback must be a length, such as "100vw". Got ${JSON.stringify(fallback)}.`);
|
|
255
|
+
rules.sort(([a], [b]) => b - a);
|
|
256
|
+
return [...rules.map(([px, size]) => `(min-width: ${px}px) ${size}`), fallback.trim()].join(", ");
|
|
257
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query parsing, validation and canonicalisation for `GET /files/*`
|
|
3
|
+
* (ADMIN_UI_OVERHAUL §0i.1, "The image processor, made tight").
|
|
4
|
+
*
|
|
5
|
+
* PURE ON PURPOSE. Nothing here touches sharp, the network or the database, so
|
|
6
|
+
* the whole parameter surface — every accepted value, every rejection, and the
|
|
7
|
+
* canonical spelling of every URL — is testable without a server and without a
|
|
8
|
+
* live Backblaze object. That matters for this route in particular: its eight
|
|
9
|
+
* parity fixtures are all 400/404 cases because a DB hit immediately leaves the
|
|
10
|
+
* process (docs/TURBINE_PORT_NOTES.md, "Deferred to the P6 cloud parity pass"),
|
|
11
|
+
* so the transform surface has no golden coverage at all unless it is reachable
|
|
12
|
+
* from a unit test.
|
|
13
|
+
*
|
|
14
|
+
* THE RULE THAT SHAPES EVERYTHING BELOW: a caller that sends only today's three
|
|
15
|
+
* parameters (`width`, `height`, `blur`) must get today's response. Every
|
|
16
|
+
* default here is therefore today's behaviour, and the one place that could
|
|
17
|
+
* have broken it — the canonical-query redirect — is deliberately gated on a
|
|
18
|
+
* NEW parameter being present. See `canonicalise`.
|
|
19
|
+
*
|
|
20
|
+
* WHY IT LIVES IN @capa/shared. `@capacms/sdk` builds image URLs, and a URL
|
|
21
|
+
* that is not spelled the way `canonicalise` spells it costs every first view
|
|
22
|
+
* a 301 that the edge then keeps for a year. So the SDK compiles this file into
|
|
23
|
+
* its own build rather than keeping a second copy of the rules, and its tests
|
|
24
|
+
* run what it builds back through `canonicalise`. The API imports it from here.
|
|
25
|
+
*/
|
|
26
|
+
export type ImageFit = "inside" | "cover" | "contain";
|
|
27
|
+
export type ImageFormat = "webp" | "avif" | "jpeg" | "png" | "auto";
|
|
28
|
+
export type ConcreteFormat = Exclude<ImageFormat, "auto">;
|
|
29
|
+
/** Content type emitted for each explicitly requestable output format. */
|
|
30
|
+
export declare const OUTPUT_MIME: Record<ConcreteFormat, string>;
|
|
31
|
+
/**
|
|
32
|
+
* The longest edge this route will ever produce.
|
|
33
|
+
*
|
|
34
|
+
* A cap rather than a clamp, because a clamp makes two different URLs return
|
|
35
|
+
* the same bytes and therefore occupy two edge objects for one representation.
|
|
36
|
+
* 4096 is the §0i.1 figure; it is also comfortably above any real layout width
|
|
37
|
+
* at dpr 3 (1365 CSS px).
|
|
38
|
+
*/
|
|
39
|
+
export declare const MAX_OUTPUT_EDGE = 4096;
|
|
40
|
+
/** Today's blur bounds, previously applied as a silent clamp. */
|
|
41
|
+
export declare const MIN_BLUR = 1;
|
|
42
|
+
export declare const MAX_BLUR = 256;
|
|
43
|
+
/** Default quality for lossy outputs except AVIF. Ignored for png, which is lossless. */
|
|
44
|
+
export declare const DEFAULT_QUALITY = 80;
|
|
45
|
+
/**
|
|
46
|
+
* AVIF's own default quality (Penelope Hospitality onboarding, #300). At 80,
|
|
47
|
+
* AVIF came back larger than WebP at 80; 60 at effort 2 is 16 to 18% smaller
|
|
48
|
+
* than WebP with equal or better SSIM on eight client photos. The route's
|
|
49
|
+
* encoder and the effort live in apps/api (image-transform.ts); the number
|
|
50
|
+
* lives here because canonicalQuery has to know it (see droppableQuality).
|
|
51
|
+
*/
|
|
52
|
+
export declare const AVIF_DEFAULT_QUALITY = 60;
|
|
53
|
+
/**
|
|
54
|
+
* The quality an encode into `output` uses when the URL names none.
|
|
55
|
+
*
|
|
56
|
+
* The route only ever calls this with a format the URL asked for, explicitly or
|
|
57
|
+
* through `auto`: with no `format` and no `quality` it selects no encoder at
|
|
58
|
+
* all and the source keeps its own format (`resolveOutputFormat`).
|
|
59
|
+
*/
|
|
60
|
+
export declare function defaultQuality(output: ConcreteFormat): number;
|
|
61
|
+
/**
|
|
62
|
+
* The `quality` canonicalQuery drops from a URL with this `format`, or null
|
|
63
|
+
* when it keeps every value.
|
|
64
|
+
*
|
|
65
|
+
* CANONICALISATION MUST NEVER CHANGE WHAT A URL RENDERS. A value may be dropped
|
|
66
|
+
* only when the URL without it encodes at that same value. After #300 gave
|
|
67
|
+
* AVIF its own default, dropping 80 everywhere sent `format=avif&quality=80`
|
|
68
|
+
* to `format=avif`, which is quality 60.
|
|
69
|
+
*
|
|
70
|
+
* - `avif`: null. 80 is not AVIF's default, so it must stay. 60 is, and could
|
|
71
|
+
* be dropped without changing the bytes; it is KEPT ON PURPOSE. The SDK's
|
|
72
|
+
* frozen URLs (#316, and @capacms/sdk's own tests) include
|
|
73
|
+
* `format=avif&quality=60`, which would start taking a 301; and an explicit
|
|
74
|
+
* number keeps meaning that number if AVIF's default is retuned, which
|
|
75
|
+
* `format=avif` does not. The cost is two edge objects for one image when a
|
|
76
|
+
* caller spells the default out.
|
|
77
|
+
* - `auto`: null. Negotiation picks AVIF (default 60) or WebP or the source's
|
|
78
|
+
* own format (default 80), so no single value means "no quality" for every
|
|
79
|
+
* browser.
|
|
80
|
+
* - `webp`, `jpeg`, `png` and no format: DEFAULT_QUALITY, as before.
|
|
81
|
+
*
|
|
82
|
+
* KNOWN GAP, older than #300 and left as it is because a URL with no `format`
|
|
83
|
+
* keeps its spelling: with no format the output follows the SOURCE, which a URL
|
|
84
|
+
* cannot see. `?quality=80&width=200` on an AVIF or HEIF source is an AVIF at
|
|
85
|
+
* 80, and `?width=200` keeps sharp's own AVIF default (50). `?quality=80` with
|
|
86
|
+
* nothing else redirects to the bare URL, which is the stored file, not a
|
|
87
|
+
* re-encode.
|
|
88
|
+
*/
|
|
89
|
+
export declare function droppableQuality(format: ImageFormat | undefined): number | null;
|
|
90
|
+
/**
|
|
91
|
+
* The parameters that existed before §0i.1.
|
|
92
|
+
*
|
|
93
|
+
* A URL built only from these is GRANDFATHERED: it is treated as already
|
|
94
|
+
* canonical and is never redirected, however it is spelled. Without this, every
|
|
95
|
+
* live `?width=100&height=50` in every customer's markup would start taking a
|
|
96
|
+
* 301 — which is not a behaviour anyone asked to change, and "keep today's
|
|
97
|
+
* calls byte-identical" outranks de-duplicating an edge object that has been
|
|
98
|
+
* duplicated for years already.
|
|
99
|
+
*/
|
|
100
|
+
export declare const LEGACY_PARAMS: Set<string>;
|
|
101
|
+
/** Parameters introduced by §0i.1. Their presence is what enables canonicalisation. */
|
|
102
|
+
export declare const NEW_PARAMS: Set<string>;
|
|
103
|
+
export interface ImageParams {
|
|
104
|
+
/** Requested width AFTER dpr multiplication. */
|
|
105
|
+
width?: number;
|
|
106
|
+
/** Requested height AFTER dpr multiplication. */
|
|
107
|
+
height?: number;
|
|
108
|
+
blur?: number;
|
|
109
|
+
format?: ImageFormat;
|
|
110
|
+
/** Present only when the caller asked for it — see `quality` in `buildSignature`. */
|
|
111
|
+
quality?: number;
|
|
112
|
+
fit: ImageFit;
|
|
113
|
+
dpr: number;
|
|
114
|
+
upscale: boolean;
|
|
115
|
+
keepMetadata: boolean;
|
|
116
|
+
/** True when any parameter that changes the bytes was supplied. */
|
|
117
|
+
wantsTransform: boolean;
|
|
118
|
+
}
|
|
119
|
+
export interface ParamRejection {
|
|
120
|
+
error: string;
|
|
121
|
+
param: string;
|
|
122
|
+
}
|
|
123
|
+
export type ParseResult = {
|
|
124
|
+
ok: true;
|
|
125
|
+
params: ImageParams;
|
|
126
|
+
} | {
|
|
127
|
+
ok: false;
|
|
128
|
+
rejection: ParamRejection;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Parse and validate the whole query in one pass.
|
|
132
|
+
*
|
|
133
|
+
* Unknown VALUES for a known parameter are rejected. Unknown KEYS are not:
|
|
134
|
+
* `?v=3` cache-busters and analytics tags are real traffic on a public CDN
|
|
135
|
+
* origin, and turning them into 400s would break callers who are not using the
|
|
136
|
+
* image surface at all. They are carried through canonicalisation untouched.
|
|
137
|
+
*/
|
|
138
|
+
export declare function parseImageParams(query: Record<string, unknown>): ParseResult;
|
|
139
|
+
export interface CanonicalResult {
|
|
140
|
+
/** The canonical query STRING, without a leading `?`. Empty when there is none. */
|
|
141
|
+
query: string;
|
|
142
|
+
/** True when the caller's spelling already matches and no redirect is owed. */
|
|
143
|
+
isCanonical: boolean;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The canonical spelling of a query, so `?width=200&format=webp` and
|
|
147
|
+
* `?format=webp&width=200` are one edge object rather than two.
|
|
148
|
+
*
|
|
149
|
+
* GATED, DELIBERATELY. Canonicalisation only applies once the caller uses a
|
|
150
|
+
* parameter introduced by §0i.1. A URL made only of `width`/`height`/`blur` is
|
|
151
|
+
* declared canonical as received, whatever its key order — see LEGACY_PARAMS
|
|
152
|
+
* for why. The gate is also what keeps the `files-404-params-ignored` parity
|
|
153
|
+
* fixture (`?width=100&height=50&blur=9`) from becoming a 301.
|
|
154
|
+
*
|
|
155
|
+
* @param rawQuery the query string as received, without the leading `?`
|
|
156
|
+
*/
|
|
157
|
+
export declare function canonicalise(rawQuery: string, params: ImageParams): CanonicalResult;
|
|
158
|
+
/**
|
|
159
|
+
* The canonical spelling itself, without `canonicalise`'s gate.
|
|
160
|
+
*
|
|
161
|
+
* `canonicalise` is this plus the LEGACY_PARAMS grandfather clause. For a query
|
|
162
|
+
* that uses a new parameter the two agree; for one made only of
|
|
163
|
+
* `width`/`height`/`blur` this still sorts and re-spells it, which the API
|
|
164
|
+
* accepts as canonical too, since the gate declares any legacy spelling
|
|
165
|
+
* canonical. So a URL built with this is never redirected, whichever
|
|
166
|
+
* parameters it carries. @capacms/sdk builds every image URL with it.
|
|
167
|
+
*
|
|
168
|
+
* @param rawQuery the query string, without the leading `?`; read only for the
|
|
169
|
+
* keys this module does not know, which are kept
|
|
170
|
+
*/
|
|
171
|
+
export declare function canonicalQuery(rawQuery: string, params: ImageParams): string;
|
|
172
|
+
/**
|
|
173
|
+
* A stable string identifying the transform, for the ETag.
|
|
174
|
+
*
|
|
175
|
+
* Built from the RESOLVED parameters rather than the URL, so two spellings of
|
|
176
|
+
* one representation share an ETag even when the redirect is not in play.
|
|
177
|
+
* `auto` resolves per request, so the caller passes the resolved format in.
|
|
178
|
+
*/
|
|
179
|
+
export declare function buildSignature(params: ImageParams, resolvedFormat?: ConcreteFormat | null): string;
|
|
180
|
+
/**
|
|
181
|
+
* Which concrete format `format=auto` resolves to for this request.
|
|
182
|
+
*
|
|
183
|
+
* Returns null when the client advertises neither modern format, in which case
|
|
184
|
+
* the source format is kept — `auto` never makes a response worse than the
|
|
185
|
+
* object already is.
|
|
186
|
+
*
|
|
187
|
+
* `q=0` is an explicit REFUSAL in RFC 9110 content negotiation, not a weak
|
|
188
|
+
* preference, so an entry carrying it is skipped rather than matched.
|
|
189
|
+
*/
|
|
190
|
+
export declare function resolveAutoFormat(accept: string | undefined | null): "avif" | "webp" | null;
|