@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
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// GENERATED by scripts/shared-image.js from packages/shared/src/image/params.ts, the rules the API serves
|
|
2
|
+
// /files/* by. Do not edit: change the source, and the API and this package follow.
|
|
3
|
+
/**
|
|
4
|
+
* Query parsing, validation and canonicalisation for `GET /files/*`
|
|
5
|
+
* (ADMIN_UI_OVERHAUL §0i.1, "The image processor, made tight").
|
|
6
|
+
*
|
|
7
|
+
* PURE ON PURPOSE. Nothing here touches sharp, the network or the database, so
|
|
8
|
+
* the whole parameter surface — every accepted value, every rejection, and the
|
|
9
|
+
* canonical spelling of every URL — is testable without a server and without a
|
|
10
|
+
* live Backblaze object. That matters for this route in particular: its eight
|
|
11
|
+
* parity fixtures are all 400/404 cases because a DB hit immediately leaves the
|
|
12
|
+
* process (docs/TURBINE_PORT_NOTES.md, "Deferred to the P6 cloud parity pass"),
|
|
13
|
+
* so the transform surface has no golden coverage at all unless it is reachable
|
|
14
|
+
* from a unit test.
|
|
15
|
+
*
|
|
16
|
+
* THE RULE THAT SHAPES EVERYTHING BELOW: a caller that sends only today's three
|
|
17
|
+
* parameters (`width`, `height`, `blur`) must get today's response. Every
|
|
18
|
+
* default here is therefore today's behaviour, and the one place that could
|
|
19
|
+
* have broken it — the canonical-query redirect — is deliberately gated on a
|
|
20
|
+
* NEW parameter being present. See `canonicalise`.
|
|
21
|
+
*
|
|
22
|
+
* WHY IT LIVES IN @capa/shared. `@capacms/sdk` builds image URLs, and a URL
|
|
23
|
+
* that is not spelled the way `canonicalise` spells it costs every first view
|
|
24
|
+
* a 301 that the edge then keeps for a year. So the SDK compiles this file into
|
|
25
|
+
* its own build rather than keeping a second copy of the rules, and its tests
|
|
26
|
+
* run what it builds back through `canonicalise`. The API imports it from here.
|
|
27
|
+
*/
|
|
28
|
+
/** Content type emitted for each explicitly requestable output format. */
|
|
29
|
+
export const OUTPUT_MIME = {
|
|
30
|
+
webp: "image/webp",
|
|
31
|
+
avif: "image/avif",
|
|
32
|
+
jpeg: "image/jpeg",
|
|
33
|
+
png: "image/png",
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The longest edge this route will ever produce.
|
|
37
|
+
*
|
|
38
|
+
* A cap rather than a clamp, because a clamp makes two different URLs return
|
|
39
|
+
* the same bytes and therefore occupy two edge objects for one representation.
|
|
40
|
+
* 4096 is the §0i.1 figure; it is also comfortably above any real layout width
|
|
41
|
+
* at dpr 3 (1365 CSS px).
|
|
42
|
+
*/
|
|
43
|
+
export const MAX_OUTPUT_EDGE = 4096;
|
|
44
|
+
/** Today's blur bounds, previously applied as a silent clamp. */
|
|
45
|
+
export const MIN_BLUR = 1;
|
|
46
|
+
export const MAX_BLUR = 256;
|
|
47
|
+
/** Default quality for lossy outputs except AVIF. Ignored for png, which is lossless. */
|
|
48
|
+
export const DEFAULT_QUALITY = 80;
|
|
49
|
+
/**
|
|
50
|
+
* AVIF's own default quality (Penelope Hospitality onboarding, #300). At 80,
|
|
51
|
+
* AVIF came back larger than WebP at 80; 60 at effort 2 is 16 to 18% smaller
|
|
52
|
+
* than WebP with equal or better SSIM on eight client photos. The route's
|
|
53
|
+
* encoder and the effort live in apps/api (image-transform.ts); the number
|
|
54
|
+
* lives here because canonicalQuery has to know it (see droppableQuality).
|
|
55
|
+
*/
|
|
56
|
+
export const AVIF_DEFAULT_QUALITY = 60;
|
|
57
|
+
/**
|
|
58
|
+
* The quality an encode into `output` uses when the URL names none.
|
|
59
|
+
*
|
|
60
|
+
* The route only ever calls this with a format the URL asked for, explicitly or
|
|
61
|
+
* through `auto`: with no `format` and no `quality` it selects no encoder at
|
|
62
|
+
* all and the source keeps its own format (`resolveOutputFormat`).
|
|
63
|
+
*/
|
|
64
|
+
export function defaultQuality(output) {
|
|
65
|
+
return output === "avif" ? AVIF_DEFAULT_QUALITY : DEFAULT_QUALITY;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The `quality` canonicalQuery drops from a URL with this `format`, or null
|
|
69
|
+
* when it keeps every value.
|
|
70
|
+
*
|
|
71
|
+
* CANONICALISATION MUST NEVER CHANGE WHAT A URL RENDERS. A value may be dropped
|
|
72
|
+
* only when the URL without it encodes at that same value. After #300 gave
|
|
73
|
+
* AVIF its own default, dropping 80 everywhere sent `format=avif&quality=80`
|
|
74
|
+
* to `format=avif`, which is quality 60.
|
|
75
|
+
*
|
|
76
|
+
* - `avif`: null. 80 is not AVIF's default, so it must stay. 60 is, and could
|
|
77
|
+
* be dropped without changing the bytes; it is KEPT ON PURPOSE. The SDK's
|
|
78
|
+
* frozen URLs (#316, and @capacms/sdk's own tests) include
|
|
79
|
+
* `format=avif&quality=60`, which would start taking a 301; and an explicit
|
|
80
|
+
* number keeps meaning that number if AVIF's default is retuned, which
|
|
81
|
+
* `format=avif` does not. The cost is two edge objects for one image when a
|
|
82
|
+
* caller spells the default out.
|
|
83
|
+
* - `auto`: null. Negotiation picks AVIF (default 60) or WebP or the source's
|
|
84
|
+
* own format (default 80), so no single value means "no quality" for every
|
|
85
|
+
* browser.
|
|
86
|
+
* - `webp`, `jpeg`, `png` and no format: DEFAULT_QUALITY, as before.
|
|
87
|
+
*
|
|
88
|
+
* KNOWN GAP, older than #300 and left as it is because a URL with no `format`
|
|
89
|
+
* keeps its spelling: with no format the output follows the SOURCE, which a URL
|
|
90
|
+
* cannot see. `?quality=80&width=200` on an AVIF or HEIF source is an AVIF at
|
|
91
|
+
* 80, and `?width=200` keeps sharp's own AVIF default (50). `?quality=80` with
|
|
92
|
+
* nothing else redirects to the bare URL, which is the stored file, not a
|
|
93
|
+
* re-encode.
|
|
94
|
+
*/
|
|
95
|
+
export function droppableQuality(format) {
|
|
96
|
+
if (format === "avif" || format === "auto")
|
|
97
|
+
return null;
|
|
98
|
+
return DEFAULT_QUALITY;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The parameters that existed before §0i.1.
|
|
102
|
+
*
|
|
103
|
+
* A URL built only from these is GRANDFATHERED: it is treated as already
|
|
104
|
+
* canonical and is never redirected, however it is spelled. Without this, every
|
|
105
|
+
* live `?width=100&height=50` in every customer's markup would start taking a
|
|
106
|
+
* 301 — which is not a behaviour anyone asked to change, and "keep today's
|
|
107
|
+
* calls byte-identical" outranks de-duplicating an edge object that has been
|
|
108
|
+
* duplicated for years already.
|
|
109
|
+
*/
|
|
110
|
+
export const LEGACY_PARAMS = new Set(["width", "height", "blur"]);
|
|
111
|
+
/** Parameters introduced by §0i.1. Their presence is what enables canonicalisation. */
|
|
112
|
+
export const NEW_PARAMS = new Set(["format", "quality", "fit", "dpr", "upscale", "keepMetadata"]);
|
|
113
|
+
/**
|
|
114
|
+
* Fastify gives `?a=1&a=2` as an array. A repeated parameter has no defensible
|
|
115
|
+
* meaning here and silently picking one is exactly the "never silently ignored"
|
|
116
|
+
* failure §0i.1 calls out, so it is a rejection.
|
|
117
|
+
*/
|
|
118
|
+
function single(raw, param) {
|
|
119
|
+
if (raw === undefined || raw === null)
|
|
120
|
+
return { ok: true };
|
|
121
|
+
if (Array.isArray(raw)) {
|
|
122
|
+
return { ok: false, rejection: { error: `\`${param}\` was given more than once`, param } };
|
|
123
|
+
}
|
|
124
|
+
if (typeof raw !== "string") {
|
|
125
|
+
return { ok: false, rejection: { error: `\`${param}\` must be a single value`, param } };
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, value: raw };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Strict integer parse.
|
|
131
|
+
*
|
|
132
|
+
* `parseInt` is what this route used to do and it is the reason `?width=abc`
|
|
133
|
+
* was a 500: `parseInt("abc")` is NaN, NaN reaches sharp, sharp throws, and the
|
|
134
|
+
* outer catch turns a bad request into "Failed to process file". `parseInt`
|
|
135
|
+
* also accepts `12px` and `0x10`. Neither is a width.
|
|
136
|
+
*/
|
|
137
|
+
function strictInt(value) {
|
|
138
|
+
if (!/^-?\d+$/.test(value.trim()))
|
|
139
|
+
return null;
|
|
140
|
+
const n = Number(value.trim());
|
|
141
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
142
|
+
}
|
|
143
|
+
function strictNumber(value) {
|
|
144
|
+
if (!/^-?\d+(\.\d+)?$/.test(value.trim()))
|
|
145
|
+
return null;
|
|
146
|
+
const n = Number(value.trim());
|
|
147
|
+
return Number.isFinite(n) ? n : null;
|
|
148
|
+
}
|
|
149
|
+
const BOOL_TRUE = new Set(["1", "true"]);
|
|
150
|
+
const BOOL_FALSE = new Set(["0", "false"]);
|
|
151
|
+
/**
|
|
152
|
+
* Parse and validate the whole query in one pass.
|
|
153
|
+
*
|
|
154
|
+
* Unknown VALUES for a known parameter are rejected. Unknown KEYS are not:
|
|
155
|
+
* `?v=3` cache-busters and analytics tags are real traffic on a public CDN
|
|
156
|
+
* origin, and turning them into 400s would break callers who are not using the
|
|
157
|
+
* image surface at all. They are carried through canonicalisation untouched.
|
|
158
|
+
*/
|
|
159
|
+
export function parseImageParams(query) {
|
|
160
|
+
const reject = (error, param) => ({ ok: false, rejection: { error, param } });
|
|
161
|
+
// --- dpr first: it multiplies the dimensions, so the cap has to see it.
|
|
162
|
+
const dprRaw = single(query.dpr, "dpr");
|
|
163
|
+
if (!dprRaw.ok)
|
|
164
|
+
return { ok: false, rejection: dprRaw.rejection };
|
|
165
|
+
let dpr = 1;
|
|
166
|
+
if (dprRaw.value !== undefined) {
|
|
167
|
+
const n = strictNumber(dprRaw.value);
|
|
168
|
+
if (n === null || n < 1 || n > 3) {
|
|
169
|
+
return reject("`dpr` must be a number between 1 and 3", "dpr");
|
|
170
|
+
}
|
|
171
|
+
dpr = n;
|
|
172
|
+
}
|
|
173
|
+
const dimension = (name) => {
|
|
174
|
+
const raw = single(query[name], name);
|
|
175
|
+
if (!raw.ok)
|
|
176
|
+
return raw;
|
|
177
|
+
if (raw.value === undefined)
|
|
178
|
+
return { ok: true };
|
|
179
|
+
const n = strictInt(raw.value);
|
|
180
|
+
if (n === null || n < 1) {
|
|
181
|
+
return { ok: false, rejection: { error: `\`${name}\` must be a positive integer`, param: name } };
|
|
182
|
+
}
|
|
183
|
+
const effective = Math.round(n * dpr);
|
|
184
|
+
if (effective > MAX_OUTPUT_EDGE) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
rejection: {
|
|
188
|
+
error: `\`${name}\` × \`dpr\` is ${effective}px; the maximum output edge is ${MAX_OUTPUT_EDGE}px`,
|
|
189
|
+
param: name,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return { ok: true, value: effective };
|
|
194
|
+
};
|
|
195
|
+
const w = dimension("width");
|
|
196
|
+
if (!w.ok)
|
|
197
|
+
return { ok: false, rejection: w.rejection };
|
|
198
|
+
const h = dimension("height");
|
|
199
|
+
if (!h.ok)
|
|
200
|
+
return { ok: false, rejection: h.rejection };
|
|
201
|
+
const blurRaw = single(query.blur, "blur");
|
|
202
|
+
if (!blurRaw.ok)
|
|
203
|
+
return { ok: false, rejection: blurRaw.rejection };
|
|
204
|
+
let blur;
|
|
205
|
+
if (blurRaw.value !== undefined) {
|
|
206
|
+
const n = strictInt(blurRaw.value);
|
|
207
|
+
// This used to be `Math.max(1, Math.min(256, parseInt(blur)))` — a silent
|
|
208
|
+
// clamp, so `blur=99999` and `blur=256` were one representation under two
|
|
209
|
+
// URLs, and `blur=abc` was a 500.
|
|
210
|
+
if (n === null || n < MIN_BLUR || n > MAX_BLUR) {
|
|
211
|
+
return reject(`\`blur\` must be an integer between ${MIN_BLUR} and ${MAX_BLUR}`, "blur");
|
|
212
|
+
}
|
|
213
|
+
blur = n;
|
|
214
|
+
}
|
|
215
|
+
const formatRaw = single(query.format, "format");
|
|
216
|
+
if (!formatRaw.ok)
|
|
217
|
+
return { ok: false, rejection: formatRaw.rejection };
|
|
218
|
+
let format;
|
|
219
|
+
if (formatRaw.value !== undefined) {
|
|
220
|
+
const v = formatRaw.value.trim().toLowerCase();
|
|
221
|
+
const canon = v === "jpg" ? "jpeg" : v;
|
|
222
|
+
if (canon !== "webp" && canon !== "avif" && canon !== "jpeg" && canon !== "png" && canon !== "auto") {
|
|
223
|
+
return reject("`format` must be one of webp, avif, jpeg, png, auto", "format");
|
|
224
|
+
}
|
|
225
|
+
format = canon;
|
|
226
|
+
}
|
|
227
|
+
const qualityRaw = single(query.quality, "quality");
|
|
228
|
+
if (!qualityRaw.ok)
|
|
229
|
+
return { ok: false, rejection: qualityRaw.rejection };
|
|
230
|
+
let quality;
|
|
231
|
+
if (qualityRaw.value !== undefined) {
|
|
232
|
+
const n = strictInt(qualityRaw.value);
|
|
233
|
+
if (n === null || n < 1 || n > 100) {
|
|
234
|
+
return reject("`quality` must be an integer between 1 and 100", "quality");
|
|
235
|
+
}
|
|
236
|
+
quality = n;
|
|
237
|
+
}
|
|
238
|
+
const fitRaw = single(query.fit, "fit");
|
|
239
|
+
if (!fitRaw.ok)
|
|
240
|
+
return { ok: false, rejection: fitRaw.rejection };
|
|
241
|
+
let fit = "inside";
|
|
242
|
+
if (fitRaw.value !== undefined) {
|
|
243
|
+
const v = fitRaw.value.trim().toLowerCase();
|
|
244
|
+
if (v !== "inside" && v !== "cover" && v !== "contain") {
|
|
245
|
+
return reject("`fit` must be one of inside, cover, contain", "fit");
|
|
246
|
+
}
|
|
247
|
+
fit = v;
|
|
248
|
+
}
|
|
249
|
+
const flag = (name) => {
|
|
250
|
+
const raw = single(query[name], name);
|
|
251
|
+
if (!raw.ok)
|
|
252
|
+
return raw;
|
|
253
|
+
if (raw.value === undefined)
|
|
254
|
+
return { ok: true, value: false };
|
|
255
|
+
const v = raw.value.trim().toLowerCase();
|
|
256
|
+
if (BOOL_TRUE.has(v))
|
|
257
|
+
return { ok: true, value: true };
|
|
258
|
+
if (BOOL_FALSE.has(v))
|
|
259
|
+
return { ok: true, value: false };
|
|
260
|
+
return { ok: false, rejection: { error: `\`${name}\` must be 0 or 1`, param: name } };
|
|
261
|
+
};
|
|
262
|
+
const upscale = flag("upscale");
|
|
263
|
+
if (!upscale.ok)
|
|
264
|
+
return { ok: false, rejection: upscale.rejection };
|
|
265
|
+
const keepMetadata = flag("keepMetadata");
|
|
266
|
+
if (!keepMetadata.ok)
|
|
267
|
+
return { ok: false, rejection: keepMetadata.rejection };
|
|
268
|
+
/**
|
|
269
|
+
* What counts as "the caller asked for a transform".
|
|
270
|
+
*
|
|
271
|
+
* `fit`, `dpr` and `upscale` only modify a resize, so on their own they
|
|
272
|
+
* change nothing — a URL carrying just `?fit=cover` must stay the pass-through
|
|
273
|
+
* it is today. `keepMetadata` likewise: there is nothing to strip if we are
|
|
274
|
+
* not re-encoding.
|
|
275
|
+
*/
|
|
276
|
+
const wantsTransform = w.value !== undefined ||
|
|
277
|
+
h.value !== undefined ||
|
|
278
|
+
blur !== undefined ||
|
|
279
|
+
format !== undefined ||
|
|
280
|
+
quality !== undefined;
|
|
281
|
+
return {
|
|
282
|
+
ok: true,
|
|
283
|
+
params: {
|
|
284
|
+
width: w.value,
|
|
285
|
+
height: h.value,
|
|
286
|
+
blur,
|
|
287
|
+
format,
|
|
288
|
+
quality,
|
|
289
|
+
fit,
|
|
290
|
+
dpr,
|
|
291
|
+
upscale: upscale.value,
|
|
292
|
+
keepMetadata: keepMetadata.value,
|
|
293
|
+
wantsTransform,
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/** Render a number the way the canonical URL spells it (no trailing zeros). */
|
|
298
|
+
function num(n) {
|
|
299
|
+
return String(Number(n));
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* One key or value of the canonical query, percent-encoded so that a browser
|
|
303
|
+
* sends it back byte for byte.
|
|
304
|
+
*
|
|
305
|
+
* `encodeURIComponent` leaves `'` as it is, but a browser's URL parser
|
|
306
|
+
* percent-encodes `'` in the query of an http(s) URL. A redirect whose Location
|
|
307
|
+
* carried a raw `'` was therefore requested as `%27`, which is not the spelling
|
|
308
|
+
* this module wrote, so it was redirected again, forever. Every other
|
|
309
|
+
* character `encodeURIComponent` leaves alone (`!` `(` `)` `*` `~` and the
|
|
310
|
+
* unreserved set) a browser leaves alone too.
|
|
311
|
+
*/
|
|
312
|
+
function encodePart(text) {
|
|
313
|
+
return encodeURIComponent(text).replace(/'/g, "%27");
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* The canonical spelling of a query, so `?width=200&format=webp` and
|
|
317
|
+
* `?format=webp&width=200` are one edge object rather than two.
|
|
318
|
+
*
|
|
319
|
+
* GATED, DELIBERATELY. Canonicalisation only applies once the caller uses a
|
|
320
|
+
* parameter introduced by §0i.1. A URL made only of `width`/`height`/`blur` is
|
|
321
|
+
* declared canonical as received, whatever its key order — see LEGACY_PARAMS
|
|
322
|
+
* for why. The gate is also what keeps the `files-404-params-ignored` parity
|
|
323
|
+
* fixture (`?width=100&height=50&blur=9`) from becoming a 301.
|
|
324
|
+
*
|
|
325
|
+
* @param rawQuery the query string as received, without the leading `?`
|
|
326
|
+
*/
|
|
327
|
+
export function canonicalise(rawQuery, params) {
|
|
328
|
+
const received = new URLSearchParams(rawQuery);
|
|
329
|
+
const usesNewParam = [...received.keys()].some((k) => NEW_PARAMS.has(k));
|
|
330
|
+
if (!usesNewParam)
|
|
331
|
+
return { query: rawQuery, isCanonical: true };
|
|
332
|
+
const query = canonicalQuery(rawQuery, params);
|
|
333
|
+
return { query, isCanonical: query === rawQuery };
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The canonical spelling itself, without `canonicalise`'s gate.
|
|
337
|
+
*
|
|
338
|
+
* `canonicalise` is this plus the LEGACY_PARAMS grandfather clause. For a query
|
|
339
|
+
* that uses a new parameter the two agree; for one made only of
|
|
340
|
+
* `width`/`height`/`blur` this still sorts and re-spells it, which the API
|
|
341
|
+
* accepts as canonical too, since the gate declares any legacy spelling
|
|
342
|
+
* canonical. So a URL built with this is never redirected, whichever
|
|
343
|
+
* parameters it carries. @capacms/sdk builds every image URL with it.
|
|
344
|
+
*
|
|
345
|
+
* @param rawQuery the query string, without the leading `?`; read only for the
|
|
346
|
+
* keys this module does not know, which are kept
|
|
347
|
+
*/
|
|
348
|
+
export function canonicalQuery(rawQuery, params) {
|
|
349
|
+
const received = new URLSearchParams(rawQuery);
|
|
350
|
+
const keys = [...new Set([...received.keys()])];
|
|
351
|
+
const out = new Map();
|
|
352
|
+
// Known parameters are re-rendered from the PARSED value, so `width=0200`,
|
|
353
|
+
// `format=JPG` and `upscale=true` all collapse onto one spelling. Defaults are
|
|
354
|
+
// dropped: a URL that asks for the default is the same representation as one
|
|
355
|
+
// that does not mention it. `quality` only where that is true for every
|
|
356
|
+
// output the URL can produce (droppableQuality).
|
|
357
|
+
//
|
|
358
|
+
// `width`/`height` are re-rendered PRE-dpr, because dpr stays in the URL as
|
|
359
|
+
// its own term; folding it into the dimension would make `?width=200&dpr=2`
|
|
360
|
+
// redirect to `?width=400`, which is a different request as far as the caller
|
|
361
|
+
// who wrote it is concerned.
|
|
362
|
+
if (params.width !== undefined)
|
|
363
|
+
out.set("width", num(Math.round(params.width / params.dpr)));
|
|
364
|
+
if (params.height !== undefined)
|
|
365
|
+
out.set("height", num(Math.round(params.height / params.dpr)));
|
|
366
|
+
if (params.blur !== undefined)
|
|
367
|
+
out.set("blur", num(params.blur));
|
|
368
|
+
if (params.format !== undefined)
|
|
369
|
+
out.set("format", params.format);
|
|
370
|
+
if (params.quality !== undefined && params.quality !== droppableQuality(params.format))
|
|
371
|
+
out.set("quality", num(params.quality));
|
|
372
|
+
if (params.fit !== "inside")
|
|
373
|
+
out.set("fit", params.fit);
|
|
374
|
+
if (params.dpr !== 1)
|
|
375
|
+
out.set("dpr", num(params.dpr));
|
|
376
|
+
if (params.upscale)
|
|
377
|
+
out.set("upscale", "1");
|
|
378
|
+
if (params.keepMetadata)
|
|
379
|
+
out.set("keepMetadata", "1");
|
|
380
|
+
// Unknown keys are preserved verbatim (first value wins, repeats collapse),
|
|
381
|
+
// so a cache-buster survives the redirect instead of being silently dropped.
|
|
382
|
+
//
|
|
383
|
+
// An empty pair (`&=`, an empty key with an empty value) is dropped: written
|
|
384
|
+
// back it would be an empty segment, which parses to nothing, so the URL it
|
|
385
|
+
// redirected to was itself redirected again.
|
|
386
|
+
for (const key of keys) {
|
|
387
|
+
if (LEGACY_PARAMS.has(key) || NEW_PARAMS.has(key))
|
|
388
|
+
continue;
|
|
389
|
+
const value = received.get(key) ?? "";
|
|
390
|
+
if (key === "" && value === "")
|
|
391
|
+
continue;
|
|
392
|
+
out.set(key, value);
|
|
393
|
+
}
|
|
394
|
+
const sorted = [...out.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
395
|
+
return sorted
|
|
396
|
+
.map(([k, v]) => (v === "" ? encodePart(k) : `${encodePart(k)}=${encodePart(v)}`))
|
|
397
|
+
.join("&");
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* A stable string identifying the transform, for the ETag.
|
|
401
|
+
*
|
|
402
|
+
* Built from the RESOLVED parameters rather than the URL, so two spellings of
|
|
403
|
+
* one representation share an ETag even when the redirect is not in play.
|
|
404
|
+
* `auto` resolves per request, so the caller passes the resolved format in.
|
|
405
|
+
*/
|
|
406
|
+
export function buildSignature(params, resolvedFormat) {
|
|
407
|
+
if (!params.wantsTransform)
|
|
408
|
+
return "raw";
|
|
409
|
+
const parts = [];
|
|
410
|
+
if (params.width !== undefined)
|
|
411
|
+
parts.push(`w${params.width}`);
|
|
412
|
+
if (params.height !== undefined)
|
|
413
|
+
parts.push(`h${params.height}`);
|
|
414
|
+
if (params.width !== undefined || params.height !== undefined) {
|
|
415
|
+
parts.push(`f${params.fit}`);
|
|
416
|
+
if (params.upscale)
|
|
417
|
+
parts.push("up");
|
|
418
|
+
}
|
|
419
|
+
if (params.blur !== undefined)
|
|
420
|
+
parts.push(`b${params.blur}`);
|
|
421
|
+
if (resolvedFormat)
|
|
422
|
+
parts.push(`o${resolvedFormat}`);
|
|
423
|
+
if (params.quality !== undefined)
|
|
424
|
+
parts.push(`q${params.quality}`);
|
|
425
|
+
if (params.keepMetadata)
|
|
426
|
+
parts.push("meta");
|
|
427
|
+
return parts.join("_") || "raw";
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Which concrete format `format=auto` resolves to for this request.
|
|
431
|
+
*
|
|
432
|
+
* Returns null when the client advertises neither modern format, in which case
|
|
433
|
+
* the source format is kept — `auto` never makes a response worse than the
|
|
434
|
+
* object already is.
|
|
435
|
+
*
|
|
436
|
+
* `q=0` is an explicit REFUSAL in RFC 9110 content negotiation, not a weak
|
|
437
|
+
* preference, so an entry carrying it is skipped rather than matched.
|
|
438
|
+
*/
|
|
439
|
+
export function resolveAutoFormat(accept) {
|
|
440
|
+
if (!accept)
|
|
441
|
+
return null;
|
|
442
|
+
const offered = new Map();
|
|
443
|
+
for (const entry of accept.split(",")) {
|
|
444
|
+
const [type, ...paramParts] = entry.split(";");
|
|
445
|
+
const name = type.trim().toLowerCase();
|
|
446
|
+
if (!name)
|
|
447
|
+
continue;
|
|
448
|
+
let q = 1;
|
|
449
|
+
for (const p of paramParts) {
|
|
450
|
+
const m = /^\s*q\s*=\s*([\d.]+)\s*$/i.exec(p);
|
|
451
|
+
if (m)
|
|
452
|
+
q = Number(m[1]);
|
|
453
|
+
}
|
|
454
|
+
offered.set(name, Number.isFinite(q) ? q : 1);
|
|
455
|
+
}
|
|
456
|
+
if ((offered.get("image/avif") ?? 0) > 0)
|
|
457
|
+
return "avif";
|
|
458
|
+
if ((offered.get("image/webp") ?? 0) > 0)
|
|
459
|
+
return "webp";
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@capacms/sdk/nextjs/image-loader`: a next/image loader that lets Capa's CDN
|
|
3
|
+
* resize, in place of Next's own image optimization.
|
|
4
|
+
*
|
|
5
|
+
* Its own entry point, apart from `@capacms/sdk/nextjs`, because next/image
|
|
6
|
+
* runs the loader in the browser: `images.loaderFile` puts this module in
|
|
7
|
+
* every page that shows an image, and this entry carries the image rules and
|
|
8
|
+
* nothing else. `@capacms/sdk/nextjs` exports the same functions for server
|
|
9
|
+
* code.
|
|
10
|
+
*
|
|
11
|
+
* ```js
|
|
12
|
+
* // next.config.mjs
|
|
13
|
+
* export default { images: { loader: "custom", loaderFile: "./capa-image-loader.js" } };
|
|
14
|
+
*
|
|
15
|
+
* // capa-image-loader.js
|
|
16
|
+
* "use client";
|
|
17
|
+
* import { createCapaImageLoader } from "@capacms/sdk/nextjs/image-loader";
|
|
18
|
+
* export default createCapaImageLoader({ format: "webp" });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import { type ImageFormat } from "../image/index.js";
|
|
22
|
+
/** What next/image passes a loader. Declared here so this module does not import `next`. */
|
|
23
|
+
export interface CapaImageLoaderProps {
|
|
24
|
+
src: string;
|
|
25
|
+
width: number;
|
|
26
|
+
quality?: number;
|
|
27
|
+
}
|
|
28
|
+
/** A next/image loader. */
|
|
29
|
+
export type CapaImageLoader = (props: CapaImageLoaderProps) => string;
|
|
30
|
+
/** What every image through the loader gets, unless next/image's own props say otherwise. */
|
|
31
|
+
export interface CapaImageLoaderOptions {
|
|
32
|
+
/**
|
|
33
|
+
* The format to encode. Left out, each image keeps its own. `auto` picks AVIF
|
|
34
|
+
* or WebP from the browser's `Accept` header; through the CDN it currently
|
|
35
|
+
* returns JPEG, so use `webp` until that is fixed.
|
|
36
|
+
*/
|
|
37
|
+
format?: ImageFormat;
|
|
38
|
+
/** Quality when an `<Image>` gives none, 1 to 100. Left out, Capa's default, 80. */
|
|
39
|
+
quality?: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A next/image loader with fixed options. A `src` that is a full Capa URL
|
|
43
|
+
* (`https://cdn.capacms.com/files/...`, or `https://api.capacms.com/files/...`,
|
|
44
|
+
* which is built on the CDN host) is resized to the width next/image asks for.
|
|
45
|
+
* Any other `src` (a file in `public/`, a bare file name, another host) is
|
|
46
|
+
* returned unchanged.
|
|
47
|
+
*
|
|
48
|
+
* The loader never throws, since a throw would fail the render of the whole
|
|
49
|
+
* page: a `src` or prop the CDN would refuse (`dpr=2` past the 4096-pixel
|
|
50
|
+
* edge, `quality=abc` in the src, `quality={0}`) gets the `src` back unchanged.
|
|
51
|
+
* The options given here are checked once, when the loader is made, so a bad
|
|
52
|
+
* one fails at build time rather than on every image.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createCapaImageLoader(options?: CapaImageLoaderOptions): CapaImageLoader;
|
|
55
|
+
/**
|
|
56
|
+
* The next/image loader with no options: each image keeps its own format, and
|
|
57
|
+
* its quality is the `<Image>`'s `quality` prop or Capa's default.
|
|
58
|
+
*/
|
|
59
|
+
export declare const capaImageLoader: CapaImageLoader;
|
|
60
|
+
export default capaImageLoader;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@capacms/sdk/nextjs/image-loader`: a next/image loader that lets Capa's CDN
|
|
3
|
+
* resize, in place of Next's own image optimization.
|
|
4
|
+
*
|
|
5
|
+
* Its own entry point, apart from `@capacms/sdk/nextjs`, because next/image
|
|
6
|
+
* runs the loader in the browser: `images.loaderFile` puts this module in
|
|
7
|
+
* every page that shows an image, and this entry carries the image rules and
|
|
8
|
+
* nothing else. `@capacms/sdk/nextjs` exports the same functions for server
|
|
9
|
+
* code.
|
|
10
|
+
*
|
|
11
|
+
* ```js
|
|
12
|
+
* // next.config.mjs
|
|
13
|
+
* export default { images: { loader: "custom", loaderFile: "./capa-image-loader.js" } };
|
|
14
|
+
*
|
|
15
|
+
* // capa-image-loader.js
|
|
16
|
+
* "use client";
|
|
17
|
+
* import { createCapaImageLoader } from "@capacms/sdk/nextjs/image-loader";
|
|
18
|
+
* export default createCapaImageLoader({ format: "webp" });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import { imageUrl, isCapaImageUrl, MAX_OUTPUT_EDGE } from "../image/index.js";
|
|
22
|
+
/**
|
|
23
|
+
* A next/image loader with fixed options. A `src` that is a full Capa URL
|
|
24
|
+
* (`https://cdn.capacms.com/files/...`, or `https://api.capacms.com/files/...`,
|
|
25
|
+
* which is built on the CDN host) is resized to the width next/image asks for.
|
|
26
|
+
* Any other `src` (a file in `public/`, a bare file name, another host) is
|
|
27
|
+
* returned unchanged.
|
|
28
|
+
*
|
|
29
|
+
* The loader never throws, since a throw would fail the render of the whole
|
|
30
|
+
* page: a `src` or prop the CDN would refuse (`dpr=2` past the 4096-pixel
|
|
31
|
+
* edge, `quality=abc` in the src, `quality={0}`) gets the `src` back unchanged.
|
|
32
|
+
* The options given here are checked once, when the loader is made, so a bad
|
|
33
|
+
* one fails at build time rather than on every image.
|
|
34
|
+
*/
|
|
35
|
+
export function createCapaImageLoader(options = {}) {
|
|
36
|
+
const { format, quality } = options;
|
|
37
|
+
try {
|
|
38
|
+
imageUrl("x", { format, quality });
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw new TypeError(String(error.message).replace("imageUrl:", "createCapaImageLoader:"));
|
|
42
|
+
}
|
|
43
|
+
return (props) => {
|
|
44
|
+
// Everything inside the try, the props' own destructuring included: a call
|
|
45
|
+
// with no props object gives back no src rather than throwing.
|
|
46
|
+
try {
|
|
47
|
+
const { src, width, quality: asked } = props;
|
|
48
|
+
if (!isCapaImageUrl(src))
|
|
49
|
+
return src;
|
|
50
|
+
return imageUrl(src, {
|
|
51
|
+
// A width past Capa's edge asks for the edge, which keeps the image resized.
|
|
52
|
+
width: Math.min(width, MAX_OUTPUT_EDGE),
|
|
53
|
+
quality: asked ?? quality,
|
|
54
|
+
format,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return props?.src;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The next/image loader with no options: each image keeps its own format, and
|
|
64
|
+
* its quality is the `<Image>`'s `quality` prop or Capa's default.
|
|
65
|
+
*/
|
|
66
|
+
export const capaImageLoader = createCapaImageLoader();
|
|
67
|
+
export default capaImageLoader;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface CapaOverlayProps {
|
|
2
|
+
/** The Capa admin origins allowed to drive the overlay. */
|
|
3
|
+
adminOrigins: string[];
|
|
4
|
+
/**
|
|
5
|
+
* How a save shows. `"in-place"` (the default) re-renders the draft with
|
|
6
|
+
* `router.refresh()`, and reloads the page only if that has not landed
|
|
7
|
+
* within `refreshTimeoutMs`. `"reload"` reloads the page on every save.
|
|
8
|
+
* The scroll position is kept either way.
|
|
9
|
+
*/
|
|
10
|
+
refresh?: "in-place" | "reload";
|
|
11
|
+
/** How long an in-place refresh may take before the page reloads instead. 10 seconds. */
|
|
12
|
+
refreshTimeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The refresh runs in a transition, so `isPending` says when the new draft
|
|
16
|
+
* has been committed to the screen, and the overlay settles the refresh
|
|
17
|
+
* then: it puts the scroll position back, and a refresh that has not
|
|
18
|
+
* committed within `refreshTimeoutMs` reloads.
|
|
19
|
+
*
|
|
20
|
+
* While the transition is pending the overlay makes a no-op state update
|
|
21
|
+
* every `REFRESH_NUDGE_MS`. React 19.2 under Next 15.5 can leave a draft
|
|
22
|
+
* refresh suspended after every part of its streamed response has arrived:
|
|
23
|
+
* the page suspends inside an already visible Suspense boundary (a root
|
|
24
|
+
* `loading.tsx` makes one around every page), and the signal that its data
|
|
25
|
+
* is ready is lost, so nothing commits until some other state update. Any
|
|
26
|
+
* state update makes React retry the suspended render, which then completes.
|
|
27
|
+
* A refresh that commits on its own stops the nudges at once. Draft mode
|
|
28
|
+
* only: a visitor never runs this component.
|
|
29
|
+
*/
|
|
30
|
+
export declare function CapaOverlay({ adminOrigins, refresh, refreshTimeoutMs }: CapaOverlayProps): null;
|