@capacms/sdk 1.0.0-next.1 → 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.
Files changed (92) hide show
  1. package/CHANGELOG.md +450 -0
  2. package/README.md +1698 -193
  3. package/bin/capa-codegen.js +192 -5
  4. package/bin/capa.js +235 -0
  5. package/bin/graphql-project.js +142 -0
  6. package/bin/project-env.js +58 -0
  7. package/dist/client.d.ts +5 -0
  8. package/dist/client.js +17 -0
  9. package/dist/codegen.d.ts +55 -0
  10. package/dist/codegen.js +320 -39
  11. package/dist/config.d.ts +5 -36
  12. package/dist/config.js +47 -1
  13. package/dist/esm/image/index.d.ts +120 -0
  14. package/dist/esm/image/index.js +250 -0
  15. package/dist/esm/image/shared-params.generated.d.ts +190 -0
  16. package/dist/esm/image/shared-params.generated.js +461 -0
  17. package/dist/esm/nextjs/image-loader.d.ts +60 -0
  18. package/dist/esm/nextjs/image-loader.js +67 -0
  19. package/dist/esm/nextjs/overlay.d.ts +30 -0
  20. package/dist/esm/nextjs/overlay.js +75 -0
  21. package/dist/esm/overlay/index.d.ts +32 -0
  22. package/dist/esm/overlay/index.js +576 -0
  23. package/dist/esm/overlay/protocol.d.ts +187 -0
  24. package/dist/esm/overlay/protocol.js +240 -0
  25. package/dist/esm/package.json +4 -0
  26. package/dist/graphql-codegen.d.ts +117 -0
  27. package/dist/graphql-codegen.js +705 -0
  28. package/dist/http.js +1 -1
  29. package/dist/image/index.d.ts +120 -0
  30. package/dist/image/index.js +257 -0
  31. package/dist/image/shared-params.generated.d.ts +190 -0
  32. package/dist/image/shared-params.generated.js +471 -0
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.js +2 -1
  35. package/dist/next/attrs.d.ts +84 -10
  36. package/dist/next/attrs.js +119 -2
  37. package/dist/next/client.d.ts +176 -32
  38. package/dist/next/client.js +212 -90
  39. package/dist/next/entry-fields.d.ts +162 -0
  40. package/dist/next/entry-fields.js +2 -0
  41. package/dist/next/errors.d.ts +136 -0
  42. package/dist/next/errors.js +214 -0
  43. package/dist/next/field-names.d.ts +37 -0
  44. package/dist/next/field-names.js +145 -0
  45. package/dist/next/graphql/build.d.ts +27 -0
  46. package/dist/next/graphql/build.js +98 -0
  47. package/dist/next/graphql/documents.d.ts +67 -0
  48. package/dist/next/graphql/documents.js +35 -0
  49. package/dist/next/graphql/edit-mode.d.ts +16 -0
  50. package/dist/next/graphql/edit-mode.js +93 -0
  51. package/dist/next/graphql/filter-values.d.ts +34 -0
  52. package/dist/next/graphql/filter-values.js +96 -0
  53. package/dist/next/graphql/introspection.d.ts +89 -0
  54. package/dist/next/graphql/introspection.js +102 -0
  55. package/dist/next/graphql/plan.d.ts +115 -0
  56. package/dist/next/graphql/plan.js +531 -0
  57. package/dist/next/graphql/request.d.ts +228 -0
  58. package/dist/next/graphql/request.js +283 -0
  59. package/dist/next/graphql/rest.d.ts +66 -0
  60. package/dist/next/graphql/rest.js +502 -0
  61. package/dist/next/graphql/selection.d.ts +55 -0
  62. package/dist/next/graphql/selection.js +212 -0
  63. package/dist/next/graphql/sha256.d.ts +13 -0
  64. package/dist/next/graphql/sha256.js +86 -0
  65. package/dist/next/graphql/summary.d.ts +83 -0
  66. package/dist/next/graphql/summary.js +151 -0
  67. package/dist/next/graphql/tree-layout.d.ts +36 -0
  68. package/dist/next/graphql/tree-layout.js +20 -0
  69. package/dist/next/graphql/tree.d.ts +171 -0
  70. package/dist/next/graphql/tree.js +249 -0
  71. package/dist/next/graphql/typed.d.ts +261 -0
  72. package/dist/next/graphql/typed.js +146 -0
  73. package/dist/next/index.d.ts +30 -5
  74. package/dist/next/index.js +32 -1
  75. package/dist/next/inflate.d.ts +51 -0
  76. package/dist/next/inflate.js +243 -0
  77. package/dist/next/key-family.d.ts +31 -0
  78. package/dist/next/key-family.js +66 -0
  79. package/dist/next/select-types.d.ts +58 -5
  80. package/dist/next/system-keys.d.ts +27 -0
  81. package/dist/next/system-keys.js +42 -0
  82. package/dist/nextjs/image-loader.d.ts +60 -0
  83. package/dist/nextjs/image-loader.js +71 -0
  84. package/dist/nextjs/index.d.ts +484 -5
  85. package/dist/nextjs/index.js +688 -6
  86. package/dist/nextjs/overlay.d.ts +30 -0
  87. package/dist/nextjs/overlay.js +78 -0
  88. package/dist/overlay/index.d.ts +14 -2
  89. package/dist/overlay/index.js +282 -43
  90. package/dist/overlay/protocol.d.ts +98 -2
  91. package/dist/overlay/protocol.js +151 -4
  92. package/package.json +63 -15
@@ -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,250 @@
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 { canonicalQuery, LEGACY_PARAMS, MAX_OUTPUT_EDGE, NEW_PARAMS, parseImageParams, } from "./shared-params.generated.js";
25
+ export { MAX_OUTPUT_EDGE };
26
+ /** Where Capa serves files. A media `url` from the API starts with this and `/files/`. */
27
+ export const CAPA_CDN_ORIGIN = "https://cdn.capacms.com";
28
+ /**
29
+ * The API's own host, which some stored URLs name (`https://api.capacms.com/files/<key>`).
30
+ * It is the CDN's origin, with nothing in front of it, so the same key is the
31
+ * same file on both; a URL built here always names the CDN.
32
+ */
33
+ const CAPA_API_ORIGIN = "https://api.capacms.com";
34
+ const CAPA_ORIGINS = new Set([CAPA_CDN_ORIGIN, CAPA_API_ORIGIN]);
35
+ const FILES_PATH = "/files/";
36
+ /** A full `https://` URL of a file on Capa's CDN or API host, as a URL, or null. */
37
+ function capaFileUrl(text) {
38
+ let url;
39
+ try {
40
+ url = new URL(text);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ if (!CAPA_ORIGINS.has(url.origin) || !url.pathname.startsWith(FILES_PATH) || url.pathname.length === FILES_PATH.length)
46
+ return null;
47
+ // The same path, query and fragment on the CDN host.
48
+ return url.origin === CAPA_CDN_ORIGIN ? url : new URL(`${url.pathname}${url.search}${url.hash}`, CAPA_CDN_ORIGIN);
49
+ }
50
+ /**
51
+ * True when `src` is a full URL of a file on Capa: `https://cdn.capacms.com/files/...`
52
+ * or `https://api.capacms.com/files/...`. A key, a path or another host is not.
53
+ * Use it to mark the other images `unoptimized` in next/image.
54
+ */
55
+ export function isCapaImageUrl(src) {
56
+ return typeof src === "string" && /^https:\/\//i.test(src) && capaFileUrl(src) !== null;
57
+ }
58
+ const OPTION_KEYS = ["width", "height", "fit", "quality", "dpr", "blur", "format"];
59
+ function positive(value) {
60
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
61
+ }
62
+ /** True when `text` names a scheme (`https:`, `data:`), so it is a URL rather than a file key. */
63
+ function hasScheme(text) {
64
+ return /^[a-z][a-z0-9+.-]*:/i.test(text);
65
+ }
66
+ /** A string or `url` as a Capa image, or why not. */
67
+ function resolveUrl(text, image) {
68
+ if (hasScheme(text)) {
69
+ const url = capaFileUrl(text);
70
+ if (url === null)
71
+ return { unchanged: text };
72
+ return { target: { url, intrinsicWidth: positive(image?.width), intrinsicHeight: positive(image?.height) } };
73
+ }
74
+ // A path (`/logo.png`, `//host/x`) is the site's own or another host's.
75
+ if (text === "" || text.startsWith("/"))
76
+ return { unchanged: text };
77
+ // A file key, which may hold folders: each segment is encoded on its own.
78
+ const path = text.split("/").map(encodeURIComponent).join("/");
79
+ return { target: { url: new URL(`${CAPA_CDN_ORIGIN}${FILES_PATH}${path}`), intrinsicWidth: positive(image?.width), intrinsicHeight: positive(image?.height) } };
80
+ }
81
+ function resolve(image) {
82
+ if (image === null || image === undefined)
83
+ return null;
84
+ if (typeof image === "string")
85
+ return resolveUrl(image);
86
+ if (typeof image !== "object" || typeof image.url !== "string" || image.url === "")
87
+ return null;
88
+ if (typeof image.type === "string" && image.type !== "image" && !image.type.startsWith("image/"))
89
+ return { unchanged: image.url };
90
+ return resolveUrl(image.url, image);
91
+ }
92
+ /** A number from a query value, when it is one. */
93
+ function numeric(value) {
94
+ if (typeof value !== "string" || !/^\d+(\.\d+)?$/.test(value.trim()))
95
+ return undefined;
96
+ return positive(Number(value));
97
+ }
98
+ /**
99
+ * Brings a request that would enlarge the image down to the image's own size.
100
+ *
101
+ * The scale the request puts on the image's content is worked out for its
102
+ * fit. `inside` and `contain` fit the image within the box, so the smaller of
103
+ * the two ratios rules and both sizes must be known; `cover` fills the box, so
104
+ * the larger rules, and with only the width known the width's ratio is a floor
105
+ * on it. Above 1, the box is divided by that scale, which keeps its shape and
106
+ * makes the content its own size. Nothing changes when the scale cannot be
107
+ * worked out. Blur is left alone: its size comes from `blur`, not the box.
108
+ */
109
+ function capToImage(query, target) {
110
+ const W = target.intrinsicWidth;
111
+ const H = target.intrinsicHeight;
112
+ if ((W === undefined && H === undefined) || query.blur !== undefined)
113
+ return;
114
+ const width = numeric(query.width);
115
+ const height = numeric(query.height);
116
+ const dpr = numeric(query.dpr) ?? 1;
117
+ const fit = typeof query.fit === "string" ? query.fit.trim().toLowerCase() : "inside";
118
+ const sw = width !== undefined && W !== undefined ? (width * dpr) / W : undefined;
119
+ const sh = height !== undefined && H !== undefined ? (height * dpr) / H : undefined;
120
+ let scale;
121
+ if (width !== undefined && height === undefined)
122
+ scale = sw;
123
+ else if (height !== undefined && width === undefined)
124
+ scale = sh;
125
+ else if (width !== undefined && height !== undefined) {
126
+ if (fit === "cover")
127
+ scale = sh === undefined ? sw : sw === undefined ? sh : Math.max(sw, sh);
128
+ else
129
+ scale = sw === undefined || sh === undefined ? undefined : Math.min(sw, sh);
130
+ }
131
+ if (scale === undefined || scale <= 1)
132
+ return;
133
+ // The epsilon keeps a division that is exact on paper (1600 / (4/3)) from flooring to one less.
134
+ const down = (n) => String(Math.max(1, Math.floor(n / scale + 1e-9)));
135
+ if (width !== undefined)
136
+ query.width = down(width);
137
+ if (height !== undefined)
138
+ query.height = down(height);
139
+ }
140
+ function fail(fn, message) {
141
+ throw new TypeError(`@capacms/sdk: ${fn}: ${message}`);
142
+ }
143
+ /** The canonical URL for a Capa image, and the pixel width it asks for. */
144
+ function build(fn, target, options) {
145
+ const { url } = target;
146
+ const search = url.search.slice(1);
147
+ // The URL's own image parameters, as Fastify would hand them to the API.
148
+ const query = {};
149
+ for (const [key, value] of new URLSearchParams(search)) {
150
+ if (!LEGACY_PARAMS.has(key) && !NEW_PARAMS.has(key))
151
+ continue;
152
+ const prior = query[key];
153
+ query[key] = prior === undefined ? value : [].concat(prior, value);
154
+ }
155
+ // The options win over them.
156
+ for (const key of OPTION_KEYS) {
157
+ const value = options[key];
158
+ if (value !== undefined && value !== null)
159
+ query[key] = String(value);
160
+ }
161
+ capToImage(query, target);
162
+ const parsed = parseImageParams(query);
163
+ if (!parsed.ok)
164
+ fail(fn, parsed.rejection.error);
165
+ const canonical = canonicalQuery(search, parsed.params);
166
+ const href = `${url.origin}${url.pathname}${canonical ? `?${canonical}` : ""}${url.hash}`;
167
+ return { href, width: parsed.params.width };
168
+ }
169
+ export function imageUrl(image, options = {}) {
170
+ const resolved = resolve(image);
171
+ if (resolved === null)
172
+ return undefined;
173
+ if ("unchanged" in resolved)
174
+ return resolved.unchanged;
175
+ return build("imageUrl", resolved.target, options).href;
176
+ }
177
+ /**
178
+ * A `srcset` for a Capa image: one candidate per width, smallest first, each
179
+ * as `<url> <width>w`. Widths past the image's own width (when known) are
180
+ * dropped in favour of one candidate at its own width.
181
+ *
182
+ * Gives `undefined` when there is nothing to resize: no image, or one that is
183
+ * not Capa's. Throws a `TypeError` for a width list that is empty or not whole
184
+ * pixels, or an option Capa would refuse.
185
+ *
186
+ * ```ts
187
+ * srcSet(entry.fields.hero, [640, 960, 1280, 1920], { format: "webp" })
188
+ * ```
189
+ */
190
+ export function srcSet(image, widths, options = {}) {
191
+ if (!Array.isArray(widths) || widths.length === 0 || !widths.every((w) => Number.isSafeInteger(w) && w > 0)) {
192
+ fail("srcSet", `widths must be a list of whole pixel widths, such as [640, 1280]. Got ${JSON.stringify(widths)}.`);
193
+ }
194
+ const loose = options;
195
+ if (loose.width !== undefined)
196
+ fail("srcSet", "srcSet takes its widths as the second argument, not a `width` option.");
197
+ if (loose.height !== undefined)
198
+ fail("srcSet", "give `aspectRatio` (width / height) rather than `height`; each candidate's height follows its width.");
199
+ if (loose.dpr !== undefined || loose.blur !== undefined) {
200
+ fail("srcSet", "`dpr` and `blur` do not apply to a srcset: the browser picks a candidate by its w descriptor.");
201
+ }
202
+ const { aspectRatio } = options;
203
+ if (aspectRatio !== undefined && positive(aspectRatio) === undefined) {
204
+ fail("srcSet", `\`aspectRatio\` must be a positive number (width / height). Got ${JSON.stringify(aspectRatio)}.`);
205
+ }
206
+ const resolved = resolve(image);
207
+ if (resolved === null || "unchanged" in resolved)
208
+ return undefined;
209
+ const seen = new Set();
210
+ const candidates = [];
211
+ for (const width of [...new Set(widths)].sort((a, b) => a - b)) {
212
+ const height = aspectRatio === undefined ? undefined : Math.max(1, Math.round(width / aspectRatio));
213
+ const { href, width: built } = build("srcSet", resolved.target, {
214
+ width,
215
+ height,
216
+ fit: options.fit,
217
+ quality: options.quality,
218
+ format: options.format,
219
+ });
220
+ if (seen.has(href))
221
+ continue;
222
+ seen.add(href);
223
+ candidates.push(`${href} ${built ?? width}w`);
224
+ }
225
+ return candidates.join(", ");
226
+ }
227
+ /**
228
+ * A `sizes` attribute from min-width breakpoints, widest first so the first
229
+ * match is the right one, then the fallback.
230
+ *
231
+ * ```ts
232
+ * sizes({ 768: "50vw", 1280: "33vw" })
233
+ * // (min-width: 1280px) 33vw, (min-width: 768px) 50vw, 100vw
234
+ * ```
235
+ */
236
+ export function sizes(breakpoints, fallback = "100vw") {
237
+ const rules = [];
238
+ for (const [key, size] of Object.entries(breakpoints)) {
239
+ const px = Number(key);
240
+ if (!/^\d+$/.test(key) || px <= 0)
241
+ fail("sizes", `breakpoints are min-widths in pixels, such as { 768: "50vw" }. Got ${JSON.stringify(key)}.`);
242
+ if (typeof size !== "string" || size.trim() === "")
243
+ fail("sizes", `the size for ${key}px must be a length, such as "50vw". Got ${JSON.stringify(size)}.`);
244
+ rules.push([px, size.trim()]);
245
+ }
246
+ if (typeof fallback !== "string" || fallback.trim() === "")
247
+ fail("sizes", `the fallback must be a length, such as "100vw". Got ${JSON.stringify(fallback)}.`);
248
+ rules.sort(([a], [b]) => b - a);
249
+ return [...rules.map(([px, size]) => `(min-width: ${px}px) ${size}`), fallback.trim()].join(", ");
250
+ }
@@ -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;