image-renditions 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,7 +62,7 @@ Create a handler for rendition URLs, for example one that reads uploads from the
62
62
 
63
63
  ```ts
64
64
  // server/renditions.ts
65
- import { S3 } from "@aws-sdk/client-s3"
65
+ import { S3Client } from "@aws-sdk/client-s3"
66
66
  import { defineRenditionEventHandler } from "image-renditions/h3"
67
67
  import { createS3Results, createS3Source } from "image-renditions/s3"
68
68
  import { createRenditionServer } from "image-renditions/server"
@@ -70,9 +70,10 @@ import { createSharpProcessor } from "image-renditions/sharp"
70
70
 
71
71
  import catalog from "#shared/renditions"
72
72
 
73
- const client = new S3({ region: "us-east-1" })
73
+ const client = new S3Client({ region: "us-east-1" })
74
74
 
75
75
  const server = createRenditionServer({
76
+ catalog,
76
77
  source: createS3Source({ client, bucket: "uploads" }),
77
78
  results: createS3Results({
78
79
  client,
@@ -82,7 +83,7 @@ const server = createRenditionServer({
82
83
  processor: createSharpProcessor(),
83
84
  })
84
85
 
85
- export default defineRenditionEventHandler({ catalog, server })
86
+ export default defineRenditionEventHandler({ server })
86
87
  ```
87
88
 
88
89
  A rendition of `avatars/42.jpg` is stored in `renditions` at `avatars/42.jpg/<hash>.avif`, and `publicUrl` is where browsers load it from.
@@ -129,6 +130,8 @@ Options of a preset, or of `defaults`, which every preset overrides field by fie
129
130
  - `quality` — encoder quality from 1 to 100, by default 50 for `avif` and 80 for `webp` and `jpeg`; `png` is lossless.
130
131
  - `effort` — CPU effort of the `avif` (0–9) and `webp` (0–6) encoders, 4 by default; other formats ignore it.
131
132
  - `densities` — pixel densities to render, `[1]` by default.
133
+ - `animation` — `first-frame` (default) renders the first frame of animated sources, and `preserve` keeps the animation of GIF and WebP sources; see [Animation](#animation).
134
+ - `originals` — extensions of sources shown as they are, so no option above applies to them; see [Originals](#originals).
132
135
 
133
136
  Options of the catalog:
134
137
 
@@ -143,11 +146,13 @@ Renaming a preset keeps its stored renditions.
143
146
  `<RenditionImg>` renders `src`, and a `srcset` when the preset has several densities.
144
147
 
145
148
  - It also renders `width` and `height` when the image always has the preset size: with `contain`, or with `cover` and `fill` when `enlarge` is set.
146
- Otherwise a small source keeps its own size, so size such images with CSS.
149
+ It renders neither for other presets, including ones that set a single side, so size their images with CSS.
147
150
  - A `src` that is already a URL, such as `blob:` or `https:`, renders unchanged, for example a preview of a file being uploaded.
148
- - A path no rendition URL can address, such as one with a `..` segment, renders no `src` instead of breaking the page.
151
+ - An unsafe path, such as one with a `..` segment, renders no `src` instead of breaking the page.
152
+ - A source whose extension the preset lists in [`originals`](#originals) renders as it is.
153
+ - A GIF or WebP source of a preset with [`animation: "preserve"`](#animation) renders its animated variants.
149
154
 
150
- `useRenditions()` builds the same URLs in code, prefixed with `app.baseURL`:
155
+ `useRenditions()` builds the same URLs in code, prefixing rendition URLs with `app.baseURL`:
151
156
 
152
157
  ```ts
153
158
  const renditions = useRenditions()
@@ -155,14 +160,19 @@ const renditions = useRenditions()
155
160
  renditions.url("cover", post.coverPath)
156
161
  renditions.url("avatar", path, { density: 2 })
157
162
  renditions.srcset("avatar", path)
158
- renditions.attrs("avatar", path) // attributes <RenditionImg> renders, for your own img element
163
+ renditions.attrs("avatar", path) // src, srcset and size of <RenditionImg>, for your own img element
159
164
  ```
160
165
 
166
+ `url()` and `srcset()` return `undefined` for an unsafe path.
167
+
161
168
  Module options, under `renditions`:
162
169
 
163
170
  - `catalog` — module exporting the catalog by default; required.
164
171
  - `handler` — optional module exporting the h3 handler by default, mounted at the catalog base path.
165
172
  - `defaultScope` — optional [scope](#scopes) for `<RenditionImg>` and `useRenditions()`.
173
+ - `originalUrl` — module building URLs of [originals](#originals); required when a preset lists `originals`.
174
+ - `imgAttrs` — attributes every `<RenditionImg>` renders, such as `{ loading: "lazy", decoding: "async" }`.
175
+ An attribute of the element overrides them, for example `loading="eager"` on an image above the fold.
166
176
 
167
177
  > For users coming from `@nuxt/image`: this module is not an `@nuxt/image` provider, so instead of `<NuxtImg>` and `useImage()`, use `<RenditionImg>` and `useRenditions()` with catalog presets.
168
178
 
@@ -180,25 +190,37 @@ Scopes and `resolveSource` let the server decide where a path lives:
180
190
 
181
191
  Without `resolveSource`, an unscoped path is the key itself, and scoped paths are rejected.
182
192
 
183
- For example, with uploads of several tenants and files shared between them:
193
+ For example, with uploads of several tenants and files shared between them, declare both scopes in the catalog:
194
+
195
+ ```ts
196
+ // shared/renditions.ts
197
+ export default defineCatalog({
198
+ scopes: ["tenant", "shared"],
199
+ presets: {
200
+ // ...
201
+ },
202
+ })
203
+ ```
204
+
205
+ - Scope names are type-checked against the catalog, like preset names.
206
+ - `catalog.url()` requires a scope.
207
+ `useRenditions()` and `<RenditionImg>` use the module's `defaultScope` when a call has no scope, and throw a `RangeError` when there is neither.
208
+ - The server rejects a target with another scope or no scope as `invalid-request`, so `resolveSource` receives only the declared scopes.
209
+ - `ScopeName<typeof catalog>` is the union of the scopes, and `PresetName<typeof catalog>` is the union of the preset names.
210
+
211
+ The catalog of the server types the scopes `resolveSource` receives, so it handles each of them:
184
212
 
185
213
  ```ts
186
214
  // server/renditions.ts
187
- const server = createRenditionServer<{ tenant: string }>({
215
+ const server = createRenditionServer({
216
+ catalog,
188
217
  // source, results and processor as in the quick start
189
- resolveSource({ scope, path }, { tenant }) {
190
- if (scope === "tenant") {
191
- return { sourceKey: `tenants/${tenant}/${path}` }
192
- }
193
- if (scope === "shared") {
194
- return { sourceKey: `shared/${path}` }
195
- }
196
- return undefined
218
+ resolveSource({ scope, path }, { tenant }: { tenant: string }) {
219
+ return { sourceKey: scope === "tenant" ? `tenants/${tenant}/${path}` : `shared/${path}` }
197
220
  },
198
221
  })
199
222
 
200
223
  export default defineRenditionEventHandler({
201
- catalog,
202
224
  server,
203
225
  // findTenant() is your lookup, for example by host.
204
226
  getContext: async event => ({ tenant: await findTenant(event) }),
@@ -206,7 +228,7 @@ export default defineRenditionEventHandler({
206
228
  ```
207
229
 
208
230
  - If `resolveSource` returns `undefined`, the request fails with `404`.
209
- - `resolveSource` receives unscoped paths too, with an undefined `scope`.
231
+ - Without scopes in the catalog, `resolveSource` receives any scope a URL has, or none, and rejects the ones it does not serve.
210
232
  - Map different scopes to different keys even for equal paths.
211
233
  - A rendition is stored in the result store at `<sourceKey>/<hash>.<extension>`.
212
234
  To keep renditions in the bucket of uploads, also return a `resultPrefix` that no source key starts with, such as `` `renditions/${sourceKey}` ``, to use instead of `sourceKey`.
@@ -216,16 +238,102 @@ export default defineRenditionEventHandler({
216
238
  Derive the context from the URL only, host included, for example from the tenant's domain: browsers and CDNs cache the redirect to a rendition by its URL for a day.
217
239
  If the context depends on cookies or a session, pass `redirectCacheControl: "no-store"` to `defineRenditionEventHandler()`, or one tenant may get the rendition of another.
218
240
 
219
- With the Nuxt module, `defaultScope: "tenant"` makes `tenant` the scope of every image, and `scope` selects another one:
241
+ With the Nuxt module, `defaultScope: "tenant"` makes `tenant` the scope of every image, and `scope` selects another one; the build fails when the catalog declares scopes without the default one:
220
242
 
221
243
  ```vue
222
244
  <RenditionImg preset="cover" :src="template.coverPath" scope="shared" alt="" />
223
245
  ```
224
246
 
247
+ ## Originals
248
+
249
+ Some sources are better shown as they are: SVG stays crisp at any zoom, and [`createSharpProcessor()`](#processing) cannot decode ICO or BMP.
250
+ `originals` lists the extensions of such sources, in lowercase and without the dot:
251
+
252
+ ```ts
253
+ export default defineCatalog({
254
+ defaults: { originals: ["svg", "ico", "bmp"] },
255
+ presets: {
256
+ logo: { height: 32 },
257
+ // Email clients do not show SVG.
258
+ "email-logo": { height: 32, originals: [] },
259
+ },
260
+ })
261
+ ```
262
+
263
+ For such a path, `<RenditionImg>` and `useRenditions()` give the URL of the source itself:
264
+
265
+ - `url()` returns it for every declared density, and `srcset()` returns `undefined`.
266
+ - `<RenditionImg>` renders no `srcset`, `width` or `height`, so size such images with CSS.
267
+ - `gif` in the list shows every GIF as it is, because a path does not tell an animated GIF from a still one.
268
+ Leave it out and set [`animation: "preserve"`](#animation) instead to shrink animated GIFs.
269
+ - Only the extension is checked, not the content, and the server still renders the source when its rendition URL is requested.
270
+ - Changing `originals` keeps the hashes of the preset.
271
+
272
+ Only the app knows where its sources are served from, so it builds their URLs.
273
+ Set the module option `originalUrl` to a module whose default export is a composable returning a function from the scope and the logical path to the URL:
274
+
275
+ ```ts
276
+ // app/renditions/original-url.ts
277
+ import { encodePath, type RenditionTarget } from "image-renditions"
278
+
279
+ export default function useOriginalUrl() {
280
+ const { uploadsUrl } = useRuntimeConfig().public
281
+ return ({ path }: RenditionTarget) => `${uploadsUrl}/${encodePath(path)}`
282
+ }
283
+ ```
284
+
285
+ ```ts
286
+ // nuxt.config.ts
287
+ export default defineNuxtConfig({
288
+ renditions: {
289
+ catalog: "#shared/renditions",
290
+ originalUrl: "~/renditions/original-url",
291
+ },
292
+ })
293
+ ```
294
+
295
+ - `useRenditions()` calls the composable every time it is called, so the composable may use other composables.
296
+ - The function receives the scope after `defaultScope`, and a path that is safe but not encoded: `encodePath()` keeps characters such as `#` and `?` inside the path.
297
+ - The URL it returns is used as is, without `app.baseURL`.
298
+ - The build fails when a preset lists `originals` and `originalUrl` is not set.
299
+
300
+ > `<img>` never runs scripts of an SVG, but an SVG opened as a page runs them on the site it is served from.
301
+ > Keep SVG files that users upload from running scripts as part of the app in any of these ways:
302
+ >
303
+ > - Serve them from a separate domain rather than a subdomain of the app.
304
+ > - Serve them with a `Content-Security-Policy: sandbox` response header.
305
+ > - Store them with `Content-Disposition: attachment`, which S3 keeps with the object, so browsers download such a file instead of opening it.
306
+
307
+ ## Animation
308
+
309
+ A rendition of an animated source shows its first frame.
310
+ `animation: "preserve"` keeps the animation of GIF and WebP sources instead:
311
+
312
+ ```ts
313
+ export default defineCatalog({
314
+ presets: {
315
+ thumb: { width: 96, height: 96 },
316
+ content: { width: 800, densities: [1, 2], animation: "preserve" },
317
+ },
318
+ })
319
+ ```
320
+
321
+ - A path with the `gif` or `webp` extension gets variants of its own, which `url()`, `srcset()` and `<RenditionImg>` choose by the extension, as a path does not tell an animated source from a still one.
322
+ - These variants are WebP and take `quality` and `effort` only from a preset whose `format` is `webp`, since values for another format do not suit WebP.
323
+ - A still GIF or WebP source renders as a still WebP image.
324
+ - Only the extension is checked, and the server renders any source with these variants when their URL is requested.
325
+ - The variants of other sources keep their hashes, so only GIF and WebP sources render again.
326
+ - [`originals`](#originals) take precedence, so a preset that lists `gif` in them shows GIF sources as they are.
327
+
328
+ To render the animated variants ahead of a request, pass the source path to `catalog.variants()`, as in [Rendering outside requests](#rendering-outside-requests).
329
+
330
+ How much of an animation a rendition keeps depends on the processor; see [Processing](#processing) for `createSharpProcessor()`.
331
+ `createRenditionServer()` fails when its processor cannot render a variant of the catalog.
332
+
225
333
  ## Storage
226
334
 
227
335
  The server reads sources from a source store and writes renditions to a result store, which may be of different kinds.
228
- Both source stores fail sources larger than 50 MiB as unsupported; pass `maxBytes` to `createS3Source()` or `createFileSystemSource()` to change the limit.
336
+ Both source stores fail sources larger than 50 MiB as `source-too-large`; pass `maxBytes` to `createS3Source()` or `createFileSystemSource()` to change the limit.
229
337
 
230
338
  ### S3 storage
231
339
 
@@ -233,12 +341,13 @@ To keep uploads and renditions in S3 buckets, use the stores of `image-rendition
233
341
 
234
342
  ```ts
235
343
  // server/renditions.ts
236
- import { S3 } from "@aws-sdk/client-s3"
344
+ import { S3Client } from "@aws-sdk/client-s3"
237
345
  import { createS3Results, createS3Source } from "image-renditions/s3"
238
346
 
239
- const client = new S3({ region: "us-east-1" })
347
+ const client = new S3Client({ region: "us-east-1" })
240
348
 
241
349
  const server = createRenditionServer({
350
+ catalog,
242
351
  source: createS3Source({ client, bucket: "uploads" }),
243
352
  results: createS3Results({
244
353
  client,
@@ -249,7 +358,7 @@ const server = createRenditionServer({
249
358
  })
250
359
  ```
251
360
 
252
- The stores take the aggregated `S3` client of AWS SDK v3 or any object with its `getObject`, `headObject` and `putObject` methods.
361
+ The stores take a client of AWS SDK v3, the bare `S3Client` or the aggregated `S3`.
253
362
  `createS3Results()` writes renditions with `Cache-Control: public, max-age=31536000, immutable`; pass it `cacheControl` to change the header.
254
363
  The bucket of renditions must serve them publicly at `publicUrl`, directly or through a CDN.
255
364
 
@@ -262,6 +371,7 @@ To keep uploads and renditions on a disk served by a web server, use the stores
262
371
  import { createFileSystemResults, createFileSystemSource } from "image-renditions/fs"
263
372
 
264
373
  const server = createRenditionServer({
374
+ catalog,
265
375
  source: createFileSystemSource({ root: "/data/uploads" }),
266
376
  results: createFileSystemResults({ root: "/data/renditions", publicUrl: "/renditions" }),
267
377
  processor: createSharpProcessor(),
@@ -278,10 +388,21 @@ location /renditions/ {
278
388
  ## Processing
279
389
 
280
390
  `createSharpProcessor()` from `image-renditions/sharp` processes images with [sharp](https://sharp.pixelplumbing.com).
281
- Sources over 100 megapixels fail as unsupported; pass `maxInputPixels` to `createSharpProcessor()` to change the limit.
391
+ Sources over 100 megapixels fail as `source-too-large`; pass `maxInputPixels` to `createSharpProcessor()` to change the limit, or `0` to remove it.
282
392
  A source that a preset would scale beyond 16383 pixels on a side, such as a narrow strip enlarged to the preset width, is scaled less to stay within that limit, so its rendition is smaller than the preset sets.
283
393
  Under `cover`, such a source is cropped instead, and its rendition keeps the preset size.
284
394
 
395
+ `createSharpProcessor()` keeps the [animation](#animation) of GIF and WebP sources.
396
+ It does not crop animations by `entropy` or `attention`, so a server fails to start with a preset that combines them.
397
+ It renders the first frame of other animated sources, such as APNG and animated AVIF, of an animation with an EXIF orientation, and of an animation beyond a limit:
398
+
399
+ - `maxFrames` — most frames, 1000 by default.
400
+ - `maxAnimationPixels` — most pixels of all frames, each counted at the larger of the source and the rendition size, 50 megapixels by default.
401
+
402
+ `0` removes a limit.
403
+ An animation at the pixel limit may take about 4 bytes of memory per pixel, and its encoder needs more, so plan memory for `limits.concurrency` such renditions at once.
404
+ Changing the limits keeps stored renditions as they are; bump `pipelineVersion` to render them again.
405
+
285
406
  To process images with another library, implement the [`Processor`](#extending) interface and pass your implementation to `createRenditionServer()` as `processor`.
286
407
 
287
408
  ## h3 and other frameworks
@@ -307,7 +428,7 @@ It takes the same options without `getContext` and returns a function from the r
307
428
  // server.ts
308
429
  import { createRenditionHandler } from "image-renditions/server"
309
430
 
310
- const renditions = createRenditionHandler({ catalog, server })
431
+ const renditions = createRenditionHandler({ server })
311
432
 
312
433
  export default {
313
434
  fetch(request: Request) {
@@ -327,16 +448,34 @@ catalog.url("avatar", { path: "avatars/42.jpg" })
327
448
  catalog.srcset("avatar", { scope: "tenant", path: "avatars/42.jpg" })
328
449
  ```
329
450
 
451
+ They throw a `RangeError` for an unsafe path; check stored paths with `isSafePath()` first.
452
+ With [originals](#originals) in the catalog, check `catalog.isOriginal()` and build the URL of such a source yourself:
453
+
454
+ ```ts
455
+ function logoUrl(path: string) {
456
+ if (!isSafePath(path)) {
457
+ return undefined
458
+ }
459
+ if (catalog.isOriginal("logo", path)) {
460
+ return `https://uploads.example.com/${encodePath(path)}`
461
+ }
462
+ return catalog.url("logo", { path })
463
+ }
464
+ ```
465
+
330
466
  ## Rendering outside requests
331
467
 
332
468
  `server.ensure()` publishes one density of a preset without a request, for example right after an upload:
333
469
 
334
470
  ```ts
335
- for (const { recipe } of catalog.variants("avatar")) {
336
- await server.ensure(recipe, { scope: "tenant", path: "avatars/42.jpg" }, { tenant: "acme" })
471
+ const path = "avatars/42.jpg"
472
+ for (const { recipe } of catalog.variants("avatar", path)) {
473
+ await server.ensure(recipe, { scope: "tenant", path }, { tenant: "acme" })
337
474
  }
338
475
  ```
339
476
 
477
+ The path chooses the [animated](#animation) variants of sources that have them.
478
+
340
479
  It resolves to the key and public URL of the rendition after it is written or found in storage.
341
480
 
342
481
  ## How a request is served
@@ -383,7 +522,8 @@ The handler responds to a failure with the status of its reason:
383
522
  - `invalid-request` (404): malformed URL, `resolveSource` rejected the target, or a key is unsafe or too long for the store
384
523
  - `unknown-recipe` (404): hash neither in the catalog nor known as published
385
524
  - `source-not-found` (404): no source under the resolved key
386
- - `unsupported-source` (422): source cannot be decoded or rendered, or exceeds a size limit
525
+ - `unsupported-source` (422): source cannot be decoded or processed
526
+ - `source-too-large` (422): source exceeds a byte or pixel limit
387
527
  - `source-error` (502): reading the source failed
388
528
  - `processing-failed` (500): processing failed for a reason other than the source
389
529
  - `storage-error` (502): checking for or writing the rendition failed
@@ -401,22 +541,25 @@ Otherwise the request fails as `unknown-recipe` without calling the stores, so m
401
541
 
402
542
  ```ts
403
543
  export default defineRenditionEventHandler({
404
- catalog,
405
544
  server,
406
545
  getContext,
407
- onFailure({ reason, error, address, context }) {
546
+ async onFailure({ reason, error, target, context }) {
408
547
  if (reason === "storage-error" || reason === "processing-failed") {
409
548
  reportError(error)
410
549
  }
411
- if (reason === "unknown-recipe" && address) {
412
- // originalUrl() is your absolute URL of the uploaded file at the scope and path of the address.
413
- return Response.redirect(originalUrl(address, context), 302)
550
+ if (reason === "unknown-recipe" && target) {
551
+ // Here sources are public, so an outdated URL shows the original instead.
552
+ const { sourceKey } = await server.resolve(target, context)
553
+ return Response.redirect(`https://uploads.example.com/${encodePath(sourceKey)}`, 302)
414
554
  }
415
555
  return undefined
416
556
  },
417
557
  })
418
558
  ```
419
559
 
560
+ - `target` is the target of the URL, absent when the URL is malformed or its scope is not declared.
561
+ - `server.resolve()` returns the keys a target resolves to with `resolveSource`, after checking that they are safe.
562
+
420
563
  Returning `undefined` keeps the default response.
421
564
  Every failure response, including a replaced one, is sent with `Cache-Control: no-store`.
422
565
 
@@ -426,6 +569,7 @@ Implement an interface from `image-renditions/server` and pass your implementati
426
569
 
427
570
  - To use other storage, implement `SourceStore` or `ResultStore` and pass it as `source` or `results`.
428
571
  - To process images with another library, implement `Processor` and pass it as `processor`.
572
+ Implement its `assertRecipe()` to reject recipes it cannot render, such as animations, when the server is created.
429
573
  - To share the index of published renditions between replicas, for example in Redis, implement `ReadinessIndex` and pass it as `readiness.shared`.
430
574
  If a call fails or exceeds `indexTimeout`, the server reports the error to `onError` and checks storage instead, while [outdated URLs](#outdated-urls) fail as `unknown-recipe`.
431
575
  - To render each rendition once across replicas, implement `Coordinator` as a shared lock and pass it as `coordinator`.
@@ -439,6 +583,7 @@ The built-in stores and processor implement the same interfaces and serve as exa
439
583
  Keep private images out of what `resolveSource` maps, for example by serving only the folders of public images.
440
584
  - A source must never change under its key: renditions of a replaced source keep showing the old image.
441
585
  Store new content under a new key.
442
- - Deleting a source leaves its renditions in storage under its result prefix.
443
- - Animated sources render their first frame, SVG sources are rasterized at the target size, and EXIF orientation is applied.
586
+ - Deleting a source leaves its renditions in storage under its result prefix, which `server.resolve()` returns.
587
+ - A rendition of an animated source shows its first frame unless the preset sets [`animation: "preserve"`](#animation), a rendition of an SVG source is rasterized at the target size, and EXIF orientation is applied.
588
+ A preset shows such sources as they are when it lists their extensions in [`originals`](#originals).
444
589
  - Source paths are limited to 1024 characters.
@@ -0,0 +1,130 @@
1
+ import { a as RenditionAnimation, c as RenditionOptions, r as Recipe, u as SourcePolicy } from "./recipe-BT8D60Qb.mjs";
2
+ //#region src/catalog.d.ts
3
+ /** Named transformation in a catalog. */
4
+ interface PresetDefinition extends RenditionOptions {
5
+ /** Pixel densities to render, as multipliers of `width` and `height`; defaults to `[1]`. */
6
+ densities?: readonly number[];
7
+ /**
8
+ * Extensions of source files shown as they are instead of rendered, such as `svg`, in lowercase and without the dot.
9
+ *
10
+ * Such sources are shown as they are whatever the other options, including `animation`.
11
+ * They do not affect hashes, and the server still renders such a source when its rendition URL is requested.
12
+ */
13
+ originals?: readonly string[];
14
+ /**
15
+ * How renditions show animated sources; `first-frame` by default.
16
+ *
17
+ * With `preserve`, sources with the `gif` or `webp` extension get variants of their own in `webp` that keep the animation, which take `quality` and `effort` only from a `webp` preset.
18
+ * The variants of other sources keep their hashes.
19
+ */
20
+ animation?: RenditionAnimation;
21
+ }
22
+ /** Shared configuration of renditions, imported by both the client and the server. */
23
+ interface CatalogDefinition<Name extends string = string, Scope extends string = string> {
24
+ /** Path the rendition handler is mounted at, `/_renditions` by default. */
25
+ basePath?: string;
26
+ /** Bumping it gives every variant a new hash, so every rendition is generated again. */
27
+ pipelineVersion?: number;
28
+ /** Options of every preset; a preset overrides them field by field. */
29
+ defaults?: PresetDefinition;
30
+ /** How special kinds of sources are rendered. */
31
+ sources?: Partial<SourcePolicy>;
32
+ /**
33
+ * Scopes a logical path can belong to, such as `tenant`; every target then has one of them.
34
+ *
35
+ * Without them, a target may have any scope or none.
36
+ */
37
+ scopes?: readonly Scope[];
38
+ presets: Record<Name, PresetDefinition>;
39
+ }
40
+ /**
41
+ * One density of a preset, which is a separate recipe with its own hash.
42
+ *
43
+ * A preset that preserves animation has a second variant at each density for GIF and WebP sources.
44
+ */
45
+ interface RenditionVariant {
46
+ readonly preset: string;
47
+ readonly density: number;
48
+ /** Readable URL label, such as `avatar` or `avatar@2x`. */
49
+ readonly label: string;
50
+ readonly recipe: Recipe;
51
+ readonly hash: string;
52
+ }
53
+ /**
54
+ * Source a rendition is made from.
55
+ *
56
+ * `Scope` is the union of the scopes a catalog declares, which makes `scope` required, or `string` for a catalog without them.
57
+ */
58
+ type RenditionTarget<Scope extends string = string> = {
59
+ /** Logical source path inside the scope. */
60
+ path: string;
61
+ } & (string extends Scope ? {
62
+ /** Application-defined space of the path, such as a tenant; omitted when the application has one space. */
63
+ scope?: string;
64
+ } : {
65
+ /** Space of the path, one of the scopes of the catalog. */
66
+ scope: Scope;
67
+ });
68
+ /** Validated catalog with its variants and URL builders. */
69
+ interface Catalog<Name extends string = string, Scope extends string = string> {
70
+ readonly basePath: string;
71
+ readonly presets: readonly Name[];
72
+ /** Scopes every target has one of, if the catalog declares them. */
73
+ readonly scopes?: readonly Scope[];
74
+ /**
75
+ * Variants of a preset ordered by density.
76
+ *
77
+ * With the path of a GIF or WebP source of a preset that preserves animation, they are the variants that keep it.
78
+ */
79
+ variants(preset: Name, path?: string): readonly RenditionVariant[];
80
+ /**
81
+ * Variant of a preset at a density, `1` by default, chosen by the source path like in `variants()`.
82
+ *
83
+ * @throws {RangeError} When the preset does not declare the density.
84
+ */
85
+ variant(preset: Name, density?: number, path?: string): RenditionVariant;
86
+ /**
87
+ * Extensions of source files a preset shows as they are instead of rendered.
88
+ *
89
+ * @throws {RangeError} When the preset is unknown.
90
+ */
91
+ originals(preset: Name): readonly string[];
92
+ /**
93
+ * Whether a preset shows a source as it is instead of rendered, judged by the extension of its path.
94
+ *
95
+ * @throws {RangeError} When the preset is unknown.
96
+ */
97
+ isOriginal(preset: Name, path: string): boolean;
98
+ /** Every variant of every preset, one per hash. */
99
+ readonly allVariants: readonly RenditionVariant[];
100
+ /** Variant with a recipe hash, if the catalog has one. */
101
+ findVariant(hash: string): RenditionVariant | undefined;
102
+ /**
103
+ * Root-relative URL of a rendition.
104
+ *
105
+ * @throws {RangeError} When the preset is unknown or does not declare the density, the scope is not one the catalog declares, or the scope or path is unsafe to put into the URL.
106
+ */
107
+ url(preset: Name, target: RenditionTarget<Scope>, density?: number): string;
108
+ /**
109
+ * `srcset` attribute value listing every density of a preset.
110
+ *
111
+ * @throws {RangeError} When the preset is unknown, the scope is not one the catalog declares, or the scope or path is unsafe to put into the URL.
112
+ */
113
+ srcset(preset: Name, target: RenditionTarget<Scope>): string;
114
+ }
115
+ /** Preset names of a catalog, such as `PresetName<typeof catalog>`. */
116
+ type PresetName<C extends Pick<Catalog, "presets">> = C["presets"][number];
117
+ /** Scopes a catalog declares, or `string` when it declares none. */
118
+ type ScopeName<C extends Pick<Catalog, "scopes">> = NonNullable<C["scopes"]>[number];
119
+ /**
120
+ * Validates a catalog and computes the recipe and hash of every preset density.
121
+ *
122
+ * Preset names are only labels: renaming a preset keeps its hashes and thus its stored renditions.
123
+ *
124
+ * @throws {RangeError} When the catalog is invalid.
125
+ */
126
+ declare function defineCatalog<const Name extends string, const Scope extends string = string>(definition: CatalogDefinition<Name, Scope> & (string extends Scope ? unknown : {
127
+ scopes: readonly Scope[];
128
+ })): Catalog<Name, Scope>;
129
+ //#endregion
130
+ export { RenditionTarget as a, defineCatalog as c, PresetName as i, CatalogDefinition as n, RenditionVariant as o, PresetDefinition as r, ScopeName as s, Catalog as t };
package/dist/fs.d.mts CHANGED
@@ -1,10 +1,10 @@
1
- import { n as ResultStore, r as SourceStore } from "./stores-DFDRMZcC.mjs";
1
+ import { n as ResultStore, r as SourceStore } from "./stores-P-TUmax4.mjs";
2
2
  //#region src/stores/fs.d.ts
3
3
  /** Options of {@link createFileSystemSource}. */
4
4
  export interface FileSystemSourceOptions {
5
5
  /** Directory source keys are resolved in. */
6
6
  root: string;
7
- /** Largest source file, in bytes; larger sources are unsupported. */
7
+ /** Largest source file, in bytes; larger sources fail as too large. */
8
8
  maxBytes?: number;
9
9
  }
10
10
  /** Options of {@link createFileSystemResults}. */
package/dist/fs.mjs CHANGED
@@ -10,7 +10,7 @@ function createFileSystemSource({ root, maxBytes = 52428800 }) {
10
10
  const path = resolvePath(root, key);
11
11
  const stats = await stat(path).catch(ignoreMissing);
12
12
  if (!stats?.isFile()) return;
13
- if (stats.size > maxBytes) throw new RenditionError("unsupported-source", `The source ${key} exceeds ${maxBytes} bytes.`);
13
+ if (stats.size > maxBytes) throw new RenditionError("source-too-large", `The source ${key} exceeds ${maxBytes} bytes.`);
14
14
  return new Uint8Array(await readFile(path, { signal }));
15
15
  } };
16
16
  }
package/dist/h3.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { r as RenditionHandlerOptions } from "./handler-BFV_vIr4.mjs";
1
+ import { r as RenditionHandlerOptions } from "./handler-9XP5SZhg.mjs";
2
2
  import { EventHandler, H3Event } from "h3";
3
3
  //#region src/h3.d.ts
4
4
  /** Source of the application context of a request. */
@@ -16,12 +16,12 @@ interface ContextOptions<Context> {
16
16
  *
17
17
  * `getContext` may be omitted when the server accepts an `undefined` context.
18
18
  */
19
- export type RenditionEventHandlerOptions<Context> = RenditionHandlerOptions<Context> & (undefined extends Context ? Partial<ContextOptions<Context>> : ContextOptions<Context>);
19
+ export type RenditionEventHandlerOptions<Context, Scope extends string = string> = RenditionHandlerOptions<Context, Scope> & (undefined extends Context ? Partial<ContextOptions<Context>> : ContextOptions<Context>);
20
20
  /**
21
21
  * Defines an h3 event handler serving rendition URLs, for h3 v1 and v2.
22
22
  *
23
23
  * Mount it at the catalog base path with a catch-all route, such as `/_renditions/**`.
24
24
  */
25
- export declare function defineRenditionEventHandler<Context>(options: RenditionEventHandlerOptions<Context>): EventHandler;
25
+ export declare function defineRenditionEventHandler<Context, Scope extends string = string>(options: RenditionEventHandlerOptions<Context, Scope>): EventHandler;
26
26
  //#endregion
27
27
  export {};
package/dist/h3.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as createRenditionHandler } from "./handler-BHMwNZnI.mjs";
1
+ import { t as createRenditionHandler } from "./handler-CPJllrYi.mjs";
2
2
  import { defineEventHandler } from "h3";
3
3
  //#region src/h3.ts
4
4
  /**