@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +450 -0
  2. package/README.md +1754 -156
  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 +98 -0
  36. package/dist/next/attrs.js +125 -0
  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 -3
  74. package/dist/next/index.js +34 -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 +704 -9
  86. package/dist/nextjs/overlay.d.ts +30 -0
  87. package/dist/nextjs/overlay.js +78 -0
  88. package/dist/overlay/index.d.ts +32 -0
  89. package/dist/overlay/index.js +596 -0
  90. package/dist/overlay/protocol.d.ts +187 -0
  91. package/dist/overlay/protocol.js +253 -0
  92. 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;