image-renditions 0.1.0 → 0.3.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.
@@ -1,4 +1,5 @@
1
- import { a as parseRenditionPath } from "./url-BIvAw2BR.mjs";
1
+ import { r as toDeclaredTarget } from "./scopes-C1kzqQHz.mjs";
2
+ import { i as parseRenditionPath } from "./url-C6PtYIEf.mjs";
2
3
  import { t as RenditionError } from "./error-DViVpVmH.mjs";
3
4
  //#region src/server/handler.ts
4
5
  const failureStatus = {
@@ -6,6 +7,7 @@ const failureStatus = {
6
7
  "unknown-recipe": 404,
7
8
  "source-not-found": 404,
8
9
  "unsupported-source": 422,
10
+ "source-too-large": 422,
9
11
  "source-error": 502,
10
12
  "processing-failed": 500,
11
13
  "storage-error": 502,
@@ -17,12 +19,14 @@ const failureStatus = {
17
19
  * A hash from the catalog is rendered on demand.
18
20
  * An unknown hash is served only when the readiness indexes know its rendition, so arbitrary hashes cost no storage calls.
19
21
  */
20
- function createRenditionHandler({ catalog, server, onFailure, redirectCacheControl = "public, max-age=86400" }) {
21
- async function fail(error, address, getContext) {
22
+ function createRenditionHandler({ server, onFailure, redirectCacheControl = "public, max-age=86400" }) {
23
+ const { catalog } = server;
24
+ async function fail(error, address, target, getContext) {
22
25
  const response = (onFailure && await onFailure({
23
26
  reason: error.reason,
24
27
  error,
25
28
  address,
29
+ target,
26
30
  context: await getContext()
27
31
  })) ?? new Response(error.reason, {
28
32
  status: failureStatus[error.reason],
@@ -48,14 +52,12 @@ function createRenditionHandler({ catalog, server, onFailure, redirectCacheContr
48
52
  }
49
53
  });
50
54
  const address = parseRenditionPath(catalog.basePath, path);
51
- if (!address) return fail(new RenditionError("invalid-request", "Malformed rendition URL."), void 0, getContext);
55
+ if (!address) return fail(new RenditionError("invalid-request", "Malformed rendition URL."), void 0, void 0, getContext);
56
+ const target = toDeclaredTarget(catalog.scopes, address.scope, address.path);
57
+ if (!target) return fail(new RenditionError("invalid-request", address.scope === void 0 ? "The URL has no scope." : `The scope ${address.scope} is not declared.`), address, void 0, getContext);
52
58
  const variant = catalog.findVariant(address.hash);
53
- if (variant && variant.recipe.format !== address.format) return fail(new RenditionError("invalid-request", "The extension does not match the recipe format."), address, getContext);
59
+ if (variant && variant.recipe.format !== address.format) return fail(new RenditionError("invalid-request", "The extension does not match the recipe format."), address, target, getContext);
54
60
  const context = await getContext();
55
- const target = {
56
- scope: address.scope,
57
- path: address.path
58
- };
59
61
  try {
60
62
  const rendition = variant ? await server.ensure(variant.recipe, target, context) : await server.findPublished(address.hash, address.format, target, context);
61
63
  if (!rendition) throw new RenditionError("unknown-recipe", `Unknown recipe ${address.hash}.`);
@@ -67,7 +69,7 @@ function createRenditionHandler({ catalog, server, onFailure, redirectCacheContr
67
69
  }
68
70
  });
69
71
  } catch (error) {
70
- if (error instanceof RenditionError) return fail(error, address, () => context);
72
+ if (error instanceof RenditionError) return fail(error, address, target, () => context);
71
73
  throw error;
72
74
  }
73
75
  };
@@ -1,8 +1,8 @@
1
- import { o as RenditionFormat, r as Recipe } from "./recipe-Je9DxqO1.mjs";
2
- import { i as RenditionTarget, t as Catalog } from "./catalog-CSnosQ1L.mjs";
3
- import { n as RenditionAddress } from "./url-BByyAbmB.mjs";
4
- import { i as StoreCallOptions, n as ResultStore, r as SourceStore } from "./stores-DFDRMZcC.mjs";
5
- import { t as Processor } from "./processor-_xuwB7Aa.mjs";
1
+ import { r as Recipe, s as RenditionFormat } from "./recipe-BT8D60Qb.mjs";
2
+ import { a as RenditionTarget, t as Catalog } from "./catalog-D11xkViN.mjs";
3
+ import { n as RenditionAddress } from "./url-DoJdUx9J.mjs";
4
+ import { i as StoreCallOptions, n as ResultStore, r as SourceStore } from "./stores-P-TUmax4.mjs";
5
+ import { t as Processor } from "./processor-DvyPs-OZ.mjs";
6
6
  //#region src/server/error.d.ts
7
7
  /** Why a rendition could not be served. */
8
8
  type RenditionFailureReason =
@@ -10,8 +10,10 @@ type RenditionFailureReason =
10
10
  "invalid-request" |
11
11
  /** The recipe hash is not in the catalog, and no rendition with it is known to be published. */
12
12
  "unknown-recipe" | "source-not-found" |
13
- /** The source cannot be decoded or rendered, or exceeds a size limit. */
13
+ /** The source cannot be decoded or processed. */
14
14
  "unsupported-source" |
15
+ /** The source exceeds a byte or pixel limit. */
16
+ "source-too-large" |
15
17
  /** Reading the source failed for a reason other than its absence. */
16
18
  "source-error" |
17
19
  /** Processing failed for a reason other than the source. */
@@ -78,7 +80,9 @@ interface SourceResolution {
78
80
  resultPrefix?: string;
79
81
  }
80
82
  /** Options of {@link createRenditionServer}. */
81
- interface RenditionServerOptions<Context> {
83
+ interface RenditionServerOptions<Context, Scope extends string = string> {
84
+ /** Catalog of the renditions the server serves, whose scopes type and limit its targets. */
85
+ catalog: Catalog<string, Scope>;
82
86
  source: SourceStore;
83
87
  results: ResultStore;
84
88
  processor: Processor;
@@ -89,7 +93,7 @@ interface RenditionServerOptions<Context> {
89
93
  * Targets of different scopes must map to different locations even when their paths are equal.
90
94
  * By default an unscoped path is the source key, and scoped targets are not served.
91
95
  */
92
- resolveSource?(target: RenditionTarget, context: Context): SourceResolution | undefined | Promise<SourceResolution | undefined>;
96
+ resolveSource?: (target: RenditionTarget<NoInfer<Scope>>, context: Context) => SourceResolution | undefined | Promise<SourceResolution | undefined>;
93
97
  readiness?: {
94
98
  /** Process-local index consulted first. */
95
99
  memory?: MemoryReadinessIndexOptions;
@@ -129,30 +133,41 @@ interface Rendition {
129
133
  url: string;
130
134
  }
131
135
  /** Generates renditions and finds published ones. */
132
- interface RenditionServer<Context> {
136
+ interface RenditionServer<Context, Scope extends string = string> {
137
+ readonly catalog: Catalog<string, Scope>;
133
138
  /**
134
139
  * Makes sure the rendition of a target is published and returns it.
135
140
  *
136
141
  * Resolves only after the rendition is written or confirmed to exist.
137
142
  *
143
+ * @throws {RangeError} When the processor cannot render the recipe.
138
144
  * @throws {RenditionError} When the rendition cannot be published.
139
145
  */
140
- ensure(recipe: Recipe, target: RenditionTarget, context: Context): Promise<Rendition>;
146
+ ensure: (recipe: Recipe, target: RenditionTarget<Scope>, context: Context) => Promise<Rendition>;
141
147
  /**
142
148
  * Finds a rendition by recipe hash in the readiness indexes, without any storage call.
143
149
  *
144
150
  * Serves renditions of recipes the catalog no longer contains.
145
151
  *
146
- * @throws {RenditionError} Of reason `invalid-request` when the target is rejected.
152
+ * @throws {RenditionError} When the target is rejected, with reason `invalid-request`.
147
153
  */
148
- findPublished(hash: string, format: RenditionFormat, target: RenditionTarget, context: Context): Promise<Rendition | undefined>;
154
+ findPublished: (hash: string, format: RenditionFormat, target: RenditionTarget<Scope>, context: Context) => Promise<Rendition | undefined>;
155
+ /**
156
+ * Storage locations of a target as `ensure()` uses them, with `resultPrefix` defaulting to `sourceKey`.
157
+ *
158
+ * It checks that the catalog declares the scope, calls `resolveSource` and checks that the keys are safe, but does not check that the source exists.
159
+ * Full rendition keys and the limits of a store are checked only when a rendition is located or stored.
160
+ *
161
+ * @throws {RenditionError} When the scope is not declared, or the target is rejected or resolves to an unsafe key, with reason `invalid-request`.
162
+ */
163
+ resolve: (target: RenditionTarget<Scope>, context: Context) => Promise<Required<SourceResolution>>;
149
164
  }
150
165
  /**
151
166
  * Creates a rendition server over source and result stores.
152
167
  *
153
- * @throws {RangeError} When a limit is not an integer in its range.
168
+ * @throws {RangeError} When a limit is not an integer in its range, or the processor cannot render a variant of the catalog.
154
169
  */
155
- declare function createRenditionServer<Context>(options: RenditionServerOptions<Context>): RenditionServer<Context>;
170
+ declare function createRenditionServer<Context, Scope extends string = string>(options: RenditionServerOptions<Context, Scope>): RenditionServer<Context, Scope>;
156
171
  //#endregion
157
172
  //#region src/server/handler.d.ts
158
173
  /** HTTP request fields a rendition handler reads. */
@@ -162,24 +177,29 @@ interface RenditionRequest {
162
177
  path: string;
163
178
  }
164
179
  /** Failed rendition request, as passed to a failure policy. */
165
- interface RenditionFailure<Context> {
180
+ interface RenditionFailure<Context, Scope extends string = string> {
166
181
  reason: RenditionFailureReason;
167
182
  error: RenditionError;
168
183
  /** Parsed rendition URL, absent when the URL is malformed. */
169
184
  address?: RenditionAddress;
185
+ /**
186
+ * Target of the URL, absent when the URL is malformed or its scope is not one the catalog declares.
187
+ *
188
+ * It does not mean that `resolveSource` accepts the target or that the source exists.
189
+ */
190
+ target?: RenditionTarget<Scope>;
170
191
  context: Context;
171
192
  }
172
193
  /** Options of {@link createRenditionHandler}. */
173
- interface RenditionHandlerOptions<Context> {
174
- /** Catalog whose recipes are generated on demand. */
175
- catalog: Catalog;
176
- server: RenditionServer<Context>;
194
+ interface RenditionHandlerOptions<Context, Scope extends string = string> {
195
+ /** Server whose catalog the handler serves. */
196
+ server: RenditionServer<Context, Scope>;
177
197
  /**
178
198
  * Builds the response to a failure; `undefined` keeps the default response with a status of the reason.
179
199
  *
180
200
  * The handler sets `Cache-Control: no-store` on every failure response, including fallbacks such as redirects to the original.
181
201
  */
182
- onFailure?(failure: RenditionFailure<Context>): Response | undefined | Promise<Response | undefined>;
202
+ onFailure?(failure: RenditionFailure<Context, Scope>): Response | undefined | Promise<Response | undefined>;
183
203
  /**
184
204
  * `Cache-Control` of redirects to published renditions; `public, max-age=86400` by default.
185
205
  *
@@ -202,6 +222,6 @@ type RenditionHandler<Context> = (request: RenditionRequest, getContext: () => C
202
222
  * A hash from the catalog is rendered on demand.
203
223
  * An unknown hash is served only when the readiness indexes know its rendition, so arbitrary hashes cost no storage calls.
204
224
  */
205
- declare function createRenditionHandler<Context>({ catalog, server, onFailure, redirectCacheControl }: RenditionHandlerOptions<Context>): RenditionHandler<Context>;
225
+ declare function createRenditionHandler<Context, Scope extends string = string>({ server, onFailure, redirectCacheControl }: RenditionHandlerOptions<Context, Scope>): RenditionHandler<Context>;
206
226
  //#endregion
207
227
  export { RenditionFailureReason as _, createRenditionHandler as a, RenditionServerOptions as c, MemoryReadinessIndexOptions as d, ReadinessIndex as f, RenditionError as g, createLocalCoordinator as h, RenditionRequest as i, SourceResolution as l, Coordinator as m, RenditionHandler as n, Rendition as o, createMemoryReadinessIndex as p, RenditionHandlerOptions as r, RenditionServer as s, RenditionFailure as t, createRenditionServer as u };
@@ -0,0 +1,16 @@
1
+ import { o as serializeRecipe } from "./recipe-BavgOq0b.mjs";
2
+ import { sha256 } from "@noble/hashes/sha2.js";
3
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
4
+ //#region src/hash.ts
5
+ /**
6
+ * Length of a recipe hash in hex digits, which keeps 48 bits of SHA-256.
7
+ *
8
+ * It tells apart the few thousand recipes an application may have over its lifetime, and a catalog rejects a collision among its own recipes.
9
+ */
10
+ const RECIPE_HASH_LENGTH = 12;
11
+ /** Identifies a recipe by the truncated SHA-256 of its canonical serialization. */
12
+ function hashRecipe(recipe) {
13
+ return bytesToHex(sha256(utf8ToBytes(serializeRecipe(recipe)))).slice(0, RECIPE_HASH_LENGTH);
14
+ }
15
+ //#endregion
16
+ export { hashRecipe as t };
@@ -0,0 +1,14 @@
1
+ //#region src/nuxt/img-attrs.ts
2
+ /** Attributes of an `img` element that belong to the rendition alone. */
3
+ const RENDITION_ATTRS = [
4
+ "src",
5
+ "srcset",
6
+ "width",
7
+ "height"
8
+ ];
9
+ /** Name of a default attribute that would set a rendition attribute, in any letter case as HTML ignores it, if there is one. */
10
+ function findRenditionAttr(imgAttrs) {
11
+ return Object.keys(imgAttrs ?? {}).find((name) => RENDITION_ATTRS.includes(name.toLowerCase()));
12
+ }
13
+ //#endregion
14
+ export { findRenditionAttr as t };
package/dist/index.d.mts CHANGED
@@ -1,9 +1,7 @@
1
- import { a as RenditionFit, c as RenditionPosition, d as formatExtensions, f as normalizeRecipe, i as RecipeInput, l as SourcePolicy, n as RECIPE_ENGINE_VERSION, o as RenditionFormat, p as serializeRecipe, r as Recipe, s as RenditionOptions, t as MAX_DIMENSION, u as formatContentTypes } from "./recipe-Je9DxqO1.mjs";
2
- import { a as RenditionVariant, i as RenditionTarget, n as CatalogDefinition, o as defineCatalog, r as PresetDefinition, t as Catalog } from "./catalog-CSnosQ1L.mjs";
3
- import { a as formatRenditionPath, i as assertBasePath, n as RenditionAddress, o as parseRenditionPath, r as URL_FORMAT_VERSION, t as DEFAULT_BASE_PATH } from "./url-BByyAbmB.mjs";
1
+ import { a as RenditionAnimation, c as RenditionOptions, d as formatContentTypes, f as formatExtensions, i as RecipeInput, l as RenditionPosition, m as serializeRecipe, n as RECIPE_ENGINE_VERSION, o as RenditionFit, p as normalizeRecipe, r as Recipe, s as RenditionFormat, t as MAX_DIMENSION, u as SourcePolicy } from "./recipe-BT8D60Qb.mjs";
2
+ import { a as RenditionTarget, c as defineCatalog, i as PresetName, n as CatalogDefinition, o as RenditionVariant, r as PresetDefinition, s as ScopeName, t as Catalog } from "./catalog-D11xkViN.mjs";
3
+ import { i as parseRenditionPath, n as RenditionAddress, r as formatRenditionPath, t as DEFAULT_BASE_PATH } from "./url-DoJdUx9J.mjs";
4
4
  //#region src/hash.d.ts
5
- /** Length of a recipe hash in hex digits, which keeps 128 bits of SHA-256. */
6
- export declare const RECIPE_HASH_LENGTH = 32;
7
5
  /** Identifies a recipe by the truncated SHA-256 of its canonical serialization. */
8
6
  export declare function hashRecipe(recipe: Recipe): string;
9
7
  //#endregion
@@ -20,4 +18,4 @@ export declare function encodePath(path: string): string;
20
18
  /** Checks that a scope is a short token that needs no URL encoding. */
21
19
  export declare function isSafeScope(scope: string): boolean;
22
20
  //#endregion
23
- export { Catalog, CatalogDefinition, DEFAULT_BASE_PATH, MAX_DIMENSION, PresetDefinition, RECIPE_ENGINE_VERSION, Recipe, RecipeInput, RenditionAddress, RenditionFit, RenditionFormat, RenditionOptions, RenditionPosition, RenditionTarget, RenditionVariant, SourcePolicy, URL_FORMAT_VERSION, assertBasePath, defineCatalog, formatContentTypes, formatExtensions, formatRenditionPath, normalizeRecipe, parseRenditionPath, serializeRecipe };
21
+ export { Catalog, CatalogDefinition, DEFAULT_BASE_PATH, MAX_DIMENSION, PresetDefinition, PresetName, RECIPE_ENGINE_VERSION, Recipe, RecipeInput, type RenditionAddress, RenditionAnimation, RenditionFit, RenditionFormat, RenditionOptions, RenditionPosition, RenditionTarget, RenditionVariant, ScopeName, SourcePolicy, defineCatalog, formatContentTypes, formatExtensions, formatRenditionPath, normalizeRecipe, parseRenditionPath, serializeRecipe };
package/dist/index.mjs CHANGED
@@ -1,7 +1,11 @@
1
- import { a as normalizeRecipe, i as formatExtensions, n as RECIPE_ENGINE_VERSION, o as serializeRecipe, r as formatContentTypes, t as MAX_DIMENSION } from "./recipe-DmOhQJWE.mjs";
2
- import { a as parseRenditionPath, i as formatRenditionPath, n as URL_FORMAT_VERSION, o as RECIPE_HASH_LENGTH, r as assertBasePath, s as hashRecipe, t as DEFAULT_BASE_PATH } from "./url-BIvAw2BR.mjs";
1
+ import { a as normalizeRecipe, i as formatExtensions, n as RECIPE_ENGINE_VERSION, o as serializeRecipe, r as formatContentTypes, t as MAX_DIMENSION } from "./recipe-BavgOq0b.mjs";
2
+ import { t as hashRecipe } from "./hash-CHipV1N6.mjs";
3
3
  import { n as isSafePath, r as isSafeScope, t as encodePath } from "./path-hNFUjCbA.mjs";
4
+ import { t as assertDeclaredScope } from "./scopes-C1kzqQHz.mjs";
5
+ import { i as parseRenditionPath, n as assertBasePath, r as formatRenditionPath, t as DEFAULT_BASE_PATH } from "./url-C6PtYIEf.mjs";
4
6
  //#region src/catalog.ts
7
+ /** Extensions of sources that get animated variants, as a path does not tell an animated source from a still one. */
8
+ const ANIMATED_EXTENSIONS = ["gif", "webp"];
5
9
  /**
6
10
  * Validates a catalog and computes the recipe and hash of every preset density.
7
11
  *
@@ -12,23 +16,34 @@ import { n as isSafePath, r as isSafeScope, t as encodePath } from "./path-hNFUj
12
16
  function defineCatalog(definition) {
13
17
  const basePath = definition.basePath ?? "/_renditions";
14
18
  assertBasePath(basePath);
19
+ const scopes = definition.scopes && Object.freeze([...definition.scopes]);
20
+ if (scopes) {
21
+ if (scopes.length === 0) throw new RangeError("Catalog scopes must not be empty.");
22
+ for (const [index, scope] of scopes.entries()) {
23
+ if (!isSafeScope(scope)) throw new RangeError(`Invalid rendition scope "${scope}".`);
24
+ if (scopes.indexOf(scope) !== index) throw new RangeError(`Duplicate rendition scope "${scope}".`);
25
+ }
26
+ }
15
27
  const pipeline = definition.pipelineVersion ?? 1;
16
28
  const presets = Object.freeze(Object.keys(definition.presets));
17
- const variantsByPreset = /* @__PURE__ */ new Map();
29
+ const presetsByName = /* @__PURE__ */ new Map();
18
30
  const variantsByHash = /* @__PURE__ */ new Map();
19
31
  for (const preset of presets) {
20
32
  if (!/^[\w-]{1,64}$/.test(preset)) throw new RangeError(`Invalid preset name "${preset}".`);
21
- const { densities = [1], ...options } = {
33
+ const { densities = [1], originals = [], animation = "first-frame", ...options } = {
22
34
  ...definition.defaults,
23
- ...withoutUndefined(definition.presets[preset])
35
+ ...omitUndefined(definition.presets[preset])
24
36
  };
25
37
  if (densities.length === 0 || new Set(densities).size !== densities.length) throw new RangeError(`Preset "${preset}" must declare unique densities.`);
26
- const variants = Object.freeze([...densities].sort((a, b) => a - b).map((density) => {
38
+ if (!originals.every((extension) => /^[\da-z]+$/.test(extension))) throw new RangeError(`Preset "${preset}" must list original extensions in lowercase and without the dot.`);
39
+ const sortedDensities = [...densities].sort((a, b) => a - b);
40
+ const createVariants = (variantOptions, animation) => Object.freeze(sortedDensities.map((density) => {
27
41
  const recipe = wrapRangeError(`preset "${preset}"`, () => normalizeRecipe({
28
- options,
42
+ options: variantOptions,
29
43
  density,
30
44
  pipeline,
31
- policy: definition.sources
45
+ policy: definition.sources,
46
+ animation
32
47
  }));
33
48
  return Object.freeze({
34
49
  preset,
@@ -38,20 +53,40 @@ function defineCatalog(definition) {
38
53
  hash: hashRecipe(recipe)
39
54
  });
40
55
  }));
41
- variantsByPreset.set(preset, variants);
42
- for (const variant of variants) if (!variantsByHash.has(variant.hash)) variantsByHash.set(variant.hash, variant);
56
+ const variants = createVariants(options, "first-frame");
57
+ const animatedVariants = animation === "first-frame" ? void 0 : createVariants(options.format === "webp" ? options : {
58
+ ...options,
59
+ format: "webp",
60
+ quality: void 0,
61
+ effort: void 0
62
+ }, animation);
63
+ presetsByName.set(preset, {
64
+ variants,
65
+ originals: Object.freeze([...originals]),
66
+ animatedVariants
67
+ });
68
+ for (const variant of [...variants, ...animatedVariants ?? []]) {
69
+ const known = variantsByHash.get(variant.hash);
70
+ if (known && serializeRecipe(known.recipe) !== serializeRecipe(variant.recipe)) throw new RangeError(`Variants "${known.label}" and "${variant.label}" have different recipes of the same hash; change pipelineVersion to change every hash.`);
71
+ if (!known) variantsByHash.set(variant.hash, variant);
72
+ }
43
73
  }
44
- function variants(preset) {
45
- const found = variantsByPreset.get(preset);
74
+ function getPreset(preset) {
75
+ const found = presetsByName.get(preset);
46
76
  if (!found) throw new RangeError(`Unknown rendition preset "${preset}".`);
47
77
  return found;
48
78
  }
49
- function variant(preset, density = 1) {
50
- const found = variants(preset).find((variant) => variant.density === density);
79
+ function variants(preset, path) {
80
+ const { variants, animatedVariants } = getPreset(preset);
81
+ return animatedVariants && path !== void 0 && hasExtension(path, ANIMATED_EXTENSIONS) ? animatedVariants : variants;
82
+ }
83
+ function variant(preset, density = 1, path) {
84
+ const found = variants(preset, path).find((variant) => variant.density === density);
51
85
  if (!found) throw new RangeError(`Preset "${preset}" has no density ${density}.`);
52
86
  return found;
53
87
  }
54
- function variantUrl({ label, hash, recipe }, { scope, path }) {
88
+ function formatVariantUrl({ label, hash, recipe }, { scope, path }) {
89
+ assertDeclaredScope(scopes, scope);
55
90
  return formatRenditionPath(basePath, {
56
91
  scope,
57
92
  label,
@@ -63,14 +98,23 @@ function defineCatalog(definition) {
63
98
  return Object.freeze({
64
99
  basePath,
65
100
  presets,
101
+ scopes,
66
102
  variants,
67
103
  variant,
104
+ originals: (preset) => getPreset(preset).originals,
105
+ isOriginal: (preset, path) => hasExtension(path, getPreset(preset).originals),
106
+ allVariants: Object.freeze([...variantsByHash.values()]),
68
107
  findVariant: (hash) => variantsByHash.get(hash),
69
- url: (preset, target, density) => variantUrl(variant(preset, density), target),
70
- srcset: (preset, target) => variants(preset).map((variant) => `${variantUrl(variant, target)} ${variant.density}x`).join(", ")
108
+ url: (preset, target, density) => formatVariantUrl(variant(preset, density, target.path), target),
109
+ srcset: (preset, target) => variants(preset, target.path).map((variant) => `${formatVariantUrl(variant, target)} ${variant.density}x`).join(", ")
71
110
  });
72
111
  }
73
- function withoutUndefined(value) {
112
+ /** Whether the extension of a path is in a list of lowercase extensions. */
113
+ function hasExtension(path, extensions) {
114
+ const extension = /\.([^./]+)$/.exec(path)?.[1]?.toLowerCase();
115
+ return extension !== void 0 && extensions.includes(extension);
116
+ }
117
+ function omitUndefined(value) {
74
118
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== void 0));
75
119
  }
76
120
  function wrapRangeError(context, fn) {
@@ -81,4 +125,4 @@ function wrapRangeError(context, fn) {
81
125
  }
82
126
  }
83
127
  //#endregion
84
- export { DEFAULT_BASE_PATH, MAX_DIMENSION, RECIPE_ENGINE_VERSION, RECIPE_HASH_LENGTH, URL_FORMAT_VERSION, assertBasePath, defineCatalog, encodePath, formatContentTypes, formatExtensions, formatRenditionPath, hashRecipe, isSafePath, isSafeScope, normalizeRecipe, parseRenditionPath, serializeRecipe };
128
+ export { DEFAULT_BASE_PATH, MAX_DIMENSION, RECIPE_ENGINE_VERSION, defineCatalog, encodePath, formatContentTypes, formatExtensions, formatRenditionPath, hashRecipe, isSafePath, isSafeScope, normalizeRecipe, parseRenditionPath, serializeRecipe };
@@ -1,31 +1,57 @@
1
- import { t as Catalog } from "../catalog-CSnosQ1L.mjs";
1
+ import { a as RenditionTarget, t as Catalog } from "../catalog-D11xkViN.mjs";
2
2
  import { PropType } from "vue";
3
3
  //#region src/nuxt/runtime.d.ts
4
4
  /** Attributes of an `img` element showing a rendition. */
5
5
  export interface RenditionImgAttrs {
6
- /** Absent for a path no rendition URL can address. */
6
+ /** Absent for an unsafe path. */
7
7
  src?: string;
8
8
  srcset?: string;
9
9
  width?: number;
10
10
  height?: number;
11
11
  }
12
+ /**
13
+ * Attributes every `<RenditionImg>` renders, such as `{ loading: "lazy", decoding: "async" }`.
14
+ *
15
+ * Attributes of an element override them; `src`, `srcset`, `width` and `height` belong to the rendition alone.
16
+ */
17
+ export type DefaultImgAttrs = Record<string, string | number | boolean> & {
18
+ src?: never;
19
+ srcset?: never;
20
+ width?: never;
21
+ height?: never;
22
+ };
12
23
  /** Options of a rendition URL. */
13
- export interface RenditionUrlOptions {
24
+ export interface RenditionUrlOptions<Scope extends string = string> {
14
25
  /** Scope of the path; defaults to the module's `defaultScope`. */
15
- scope?: string;
26
+ scope?: Scope;
16
27
  /** Density of the variant; defaults to `1`. */
17
28
  density?: number;
18
29
  }
30
+ /** Options of {@link createRenditionHelpers}. */
31
+ export interface RenditionHelpersOptions<Scope extends string = string> {
32
+ /** Scope of calls that set none. */
33
+ defaultScope?: Scope;
34
+ /**
35
+ * Composable returning a function that builds the URL of a source shown as it is; required when a preset lists `originals`.
36
+ *
37
+ * The function receives a safe logical path, which it must encode.
38
+ */
39
+ useOriginalUrl?: () => (target: RenditionTarget<Scope>) => string;
40
+ imgAttrs?: DefaultImgAttrs;
41
+ }
19
42
  /**
20
43
  * Binds `useRenditions()` and `<RenditionImg>` to a catalog.
21
44
  *
22
45
  * The Nuxt module calls it in a generated module; applications use the generated helpers.
46
+ *
47
+ * @throws {Error} When a preset lists `originals` and `useOriginalUrl` is missing.
48
+ * @throws {RangeError} When `defaultScope` is not one of the scopes the catalog declares, or `imgAttrs` sets a rendition attribute.
23
49
  */
24
- export declare function createRenditionHelpers<Name extends string>(catalog: Catalog<Name>, defaultScope?: string): {
50
+ export declare function createRenditionHelpers<Name extends string, Scope extends string = string>(catalog: Catalog<Name, Scope>, { defaultScope, useOriginalUrl, imgAttrs }?: RenditionHelpersOptions<NoInfer<Scope>>): {
25
51
  useRenditions: () => {
26
- url: (preset: Name, path: string, { scope, density }?: RenditionUrlOptions) => string;
27
- srcset: (preset: Name, path: string, { scope }?: Pick<RenditionUrlOptions, "scope">) => string;
28
- attrs: (preset: Name, path: string, options?: Pick<RenditionUrlOptions, "scope">) => RenditionImgAttrs;
52
+ url: (preset: Name, path: string, { scope, density }?: RenditionUrlOptions<Scope>) => string | undefined;
53
+ srcset: (preset: Name, path: string, { scope }?: Pick<RenditionUrlOptions<Scope>, "scope">) => string | undefined;
54
+ attrs: (preset: Name, path: string, { scope }?: Pick<RenditionUrlOptions<Scope>, "scope">) => RenditionImgAttrs;
29
55
  };
30
56
  RenditionImg: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
31
57
  preset: {
@@ -38,7 +64,7 @@ export declare function createRenditionHelpers<Name extends string>(catalog: Cat
38
64
  required: true;
39
65
  };
40
66
  scope: {
41
- type: StringConstructor;
67
+ type: PropType<Scope>;
42
68
  default: undefined;
43
69
  };
44
70
  }>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
@@ -54,11 +80,11 @@ export declare function createRenditionHelpers<Name extends string>(catalog: Cat
54
80
  required: true;
55
81
  };
56
82
  scope: {
57
- type: StringConstructor;
83
+ type: PropType<Scope>;
58
84
  default: undefined;
59
85
  };
60
86
  }>> & Readonly<{}>, {
61
- scope: string;
87
+ scope: unknown extends Scope ? keyof Scope extends never ? undefined : Scope : Scope;
62
88
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
63
89
  };
64
90
  //#endregion
@@ -1,4 +1,6 @@
1
- import { n as isSafePath } from "../path-hNFUjCbA.mjs";
1
+ import { n as isSafePath, r as isSafeScope } from "../path-hNFUjCbA.mjs";
2
+ import { n as isDeclaredScope, r as toDeclaredTarget, t as assertDeclaredScope } from "../scopes-C1kzqQHz.mjs";
3
+ import { t as findRenditionAttr } from "../img-attrs-BoCAHpl5.mjs";
2
4
  import { useRuntimeConfig } from "nuxt/app";
3
5
  import { defineComponent, h } from "vue";
4
6
  //#region src/nuxt/runtime.ts
@@ -6,38 +8,82 @@ import { defineComponent, h } from "vue";
6
8
  * Binds `useRenditions()` and `<RenditionImg>` to a catalog.
7
9
  *
8
10
  * The Nuxt module calls it in a generated module; applications use the generated helpers.
11
+ *
12
+ * @throws {Error} When a preset lists `originals` and `useOriginalUrl` is missing.
13
+ * @throws {RangeError} When `defaultScope` is not one of the scopes the catalog declares, or `imgAttrs` sets a rendition attribute.
9
14
  */
10
- function createRenditionHelpers(catalog, defaultScope) {
15
+ function createRenditionHelpers(catalog, { defaultScope, useOriginalUrl, imgAttrs } = {}) {
16
+ if (!useOriginalUrl && catalog.presets.some((preset) => catalog.originals(preset).length > 0)) throw new Error("[image-renditions] Pass `useOriginalUrl`, as a preset of the catalog lists `originals`.");
17
+ if (defaultScope !== void 0 && !isDeclaredScope(catalog.scopes, defaultScope)) throw new RangeError(`Undeclared default rendition scope "${defaultScope}".`);
18
+ const renditionAttr = findRenditionAttr(imgAttrs);
19
+ if (renditionAttr) throw new RangeError(`Default image attribute "${renditionAttr}" is set by the rendition.`);
11
20
  /** Builds rendition URLs below the application base URL. */
12
21
  function useRenditions() {
13
22
  const base = useRuntimeConfig().app.baseURL.replace(/\/$/, "");
23
+ const originalUrl = useOriginalUrl?.();
24
+ /**
25
+ * Target of a path in the scope of a call, or `undefined` for an unsafe path.
26
+ *
27
+ * The scope is checked here because the URL of an original does not go through the rendition URL builder, which checks it too.
28
+ */
29
+ function resolveTarget(path, scope) {
30
+ if (!isSafePath(path)) return;
31
+ const resolved = scope ?? defaultScope;
32
+ if (resolved !== void 0 && !isSafeScope(resolved)) throw new RangeError(`Invalid rendition scope "${resolved}".`);
33
+ assertDeclaredScope(catalog.scopes, resolved);
34
+ return toDeclaredTarget(catalog.scopes, resolved, path);
35
+ }
36
+ function formatUrl(preset, target, density) {
37
+ return base + catalog.url(preset, target, density);
38
+ }
39
+ function formatSrcset(preset, target) {
40
+ return catalog.variants(preset).map(({ density }) => `${formatUrl(preset, target, density)} ${density}x`).join(", ");
41
+ }
42
+ /**
43
+ * URL of a rendition of a logical path, or of the source itself at every declared density when the preset lists its extension in `originals`.
44
+ *
45
+ * Returns `undefined` for an unsafe path, such as one with a `..` segment, so stored data never breaks rendering.
46
+ *
47
+ * @throws {RangeError} When the preset is unknown or does not declare the density, or the scope is invalid or not one the catalog declares.
48
+ */
14
49
  function url(preset, path, { scope, density } = {}) {
15
- return base + catalog.url(preset, {
16
- scope: scope ?? defaultScope,
17
- path
18
- }, density);
50
+ const target = resolveTarget(path, scope);
51
+ if (!target) return;
52
+ if (catalog.isOriginal(preset, path)) {
53
+ catalog.variant(preset, density);
54
+ return originalUrl(target);
55
+ }
56
+ return formatUrl(preset, target, density);
19
57
  }
58
+ /**
59
+ * `srcset` attribute value listing every density of a preset.
60
+ *
61
+ * Returns `undefined` for an unsafe path, like `url()`, and for a source shown as it is, which has no densities.
62
+ *
63
+ * @throws {RangeError} When the preset is unknown, or the scope is invalid or not one the catalog declares.
64
+ */
20
65
  function srcset(preset, path, { scope } = {}) {
21
- return catalog.variants(preset).map(({ density }) => `${url(preset, path, {
22
- scope,
23
- density
24
- })} ${density}x`).join(", ");
66
+ const target = resolveTarget(path, scope);
67
+ return target && !catalog.isOriginal(preset, path) ? formatSrcset(preset, target) : void 0;
25
68
  }
26
69
  /**
27
70
  * Attributes of an `img` element showing every density of a preset.
28
71
  *
29
72
  * `width` and `height` are set only when the output always has the size of the preset box, so they never distort the image.
30
73
  * A source that is already a URL, such as a `blob:`, `data:` or `https:` one, is returned unchanged as `src`.
31
- * A path no rendition URL can address, such as one with a `..` segment, yields no `src`, so stored data never breaks rendering.
74
+ * A source whose extension the preset lists in `originals` gets only the `src` of the source itself.
75
+ * An unsafe path, such as one with a `..` segment, yields no `src`, so stored data never breaks rendering.
32
76
  */
33
- function attrs(preset, path, options = {}) {
77
+ function attrs(preset, path, { scope } = {}) {
34
78
  if (/^(?:[a-z][\d+.a-z-]*:|\/\/)/i.test(path)) return { src: path };
35
- if (!isSafePath(path)) return {};
79
+ const target = resolveTarget(path, scope);
80
+ if (!target) return {};
81
+ if (catalog.isOriginal(preset, path)) return { src: originalUrl(target) };
36
82
  const { recipe } = catalog.variant(preset);
37
83
  const boxed = recipe.fit === "contain" || (recipe.fit === "cover" || recipe.fit === "fill") && recipe.enlarge;
38
84
  return {
39
- src: url(preset, path, options),
40
- srcset: catalog.variants(preset).length > 1 ? srcset(preset, path, options) : void 0,
85
+ src: formatUrl(preset, target),
86
+ srcset: catalog.variants(preset).length > 1 ? formatSrcset(preset, target) : void 0,
41
87
  width: boxed ? recipe.width : void 0,
42
88
  height: boxed ? recipe.height : void 0
43
89
  };
@@ -69,7 +115,12 @@ function createRenditionHelpers(catalog, defaultScope) {
69
115
  },
70
116
  setup(props) {
71
117
  const renditions = useRenditions();
72
- return () => h("img", renditions.attrs(props.preset, props.src, { scope: props.scope }));
118
+ return () => {
119
+ return h("img", {
120
+ ...imgAttrs,
121
+ ...renditions.attrs(props.preset, props.src, { scope: props.scope })
122
+ });
123
+ };
73
124
  }
74
125
  })
75
126
  };
package/dist/nuxt.d.mts CHANGED
@@ -1,3 +1,4 @@
1
+ import { DefaultImgAttrs } from "./nuxt/runtime.mjs";
1
2
  import { NuxtModule } from "nuxt/schema";
2
3
  //#region src/nuxt/module.d.ts
3
4
  /** Options of the `image-renditions/nuxt` module, under the `renditions` key of the Nuxt config. */
@@ -10,8 +11,17 @@ export interface ModuleOptions {
10
11
  catalog?: string;
11
12
  /** Module whose default export is the h3 event handler of renditions, mounted at the catalog base path. */
12
13
  handler?: string;
13
- /** Scope for `<RenditionImg>` and `useRenditions()`; their `scope` overrides it. */
14
+ /** Scope for `<RenditionImg>` and `useRenditions()`, one of the catalog scopes if it declares them; their `scope` overrides it. */
14
15
  defaultScope?: string;
16
+ /**
17
+ * Module whose default export is a composable returning a function that builds the URL of a source shown as it is, such as `~/renditions/original-url`.
18
+ *
19
+ * Required when a preset lists `originals`.
20
+ * The function receives the scope, after `defaultScope`, and a safe logical path, which it must encode.
21
+ */
22
+ originalUrl?: string;
23
+ /** Attributes every `<RenditionImg>` renders, such as `{ loading: "lazy", decoding: "async" }`; attributes of an element override them. */
24
+ imgAttrs?: DefaultImgAttrs;
15
25
  }
16
26
  /**
17
27
  * Nuxt module that mounts the rendition handler and provides `useRenditions()` and `<RenditionImg>` typed by the catalog.