@decocms/blocks 7.50.1 → 7.52.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/package.json +1 -1
- package/src/cms/draftSource.test.ts +86 -0
- package/src/cms/draftSource.ts +52 -8
- package/src/cms/index.ts +1 -0
- package/src/hooks/Image.test.ts +86 -1
- package/src/hooks/Image.tsx +56 -0
- package/src/hooks/index.ts +3 -0
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
previewApiOriginForHost,
|
|
11
11
|
resolveDraftDecofile,
|
|
12
12
|
resolveDraftForRequest,
|
|
13
|
+
setDecoSiteHost,
|
|
13
14
|
setDraftPreviewHosts,
|
|
14
15
|
} from "./draftSource";
|
|
15
16
|
|
|
@@ -323,6 +324,91 @@ describe("site-block preview hosts", () => {
|
|
|
323
324
|
});
|
|
324
325
|
});
|
|
325
326
|
|
|
327
|
+
describe("deco-hosted preview domains (setDecoSiteHost)", () => {
|
|
328
|
+
it("infers <site>.deco.site and enables the feature", () => {
|
|
329
|
+
setDecoSiteHost("als-storefront");
|
|
330
|
+
try {
|
|
331
|
+
expect(isDraftPreviewEnabled({})).toBe(true);
|
|
332
|
+
expect(isDraftHostAllowed("als-storefront.deco.site", {})).toBe(true);
|
|
333
|
+
expect(isDraftHostAllowed("other.deco.site", {})).toBe(false);
|
|
334
|
+
// A custom production domain is never inferred.
|
|
335
|
+
expect(isDraftHostAllowed("www.als-storefront.com", {})).toBe(false);
|
|
336
|
+
} finally {
|
|
337
|
+
setDecoSiteHost(null);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("infers the <site>.deco-cx.workers.dev deploy host", () => {
|
|
342
|
+
setDecoSiteHost("casaevideo-tanstack");
|
|
343
|
+
try {
|
|
344
|
+
expect(isDraftHostAllowed("casaevideo-tanstack.deco-cx.workers.dev", {})).toBe(true);
|
|
345
|
+
// Exact match only — another worker on the same account is not admitted.
|
|
346
|
+
expect(isDraftHostAllowed("other-site.deco-cx.workers.dev", {})).toBe(false);
|
|
347
|
+
// No nested subdomain widens the match.
|
|
348
|
+
expect(
|
|
349
|
+
isDraftHostAllowed("casaevideo-tanstack.evil.deco-cx.workers.dev", {}),
|
|
350
|
+
).toBe(false);
|
|
351
|
+
// Wrong apex.
|
|
352
|
+
expect(isDraftHostAllowed("casaevideo-tanstack.deco-cx.workers.example", {})).toBe(false);
|
|
353
|
+
} finally {
|
|
354
|
+
setDecoSiteHost(null);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("is merged ON TOP of the site block, not replacing it", () => {
|
|
359
|
+
setDraftPreviewHosts(["fila.vtex.app"]);
|
|
360
|
+
setDecoSiteHost("als-storefront");
|
|
361
|
+
try {
|
|
362
|
+
expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(true);
|
|
363
|
+
expect(isDraftHostAllowed("als-storefront.deco.site", {})).toBe(true);
|
|
364
|
+
} finally {
|
|
365
|
+
setDraftPreviewHosts([]);
|
|
366
|
+
setDecoSiteHost(null);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("is merged ON TOP of the env escape hatch too", () => {
|
|
371
|
+
setDecoSiteHost("als-storefront");
|
|
372
|
+
try {
|
|
373
|
+
const env = { DECO_ALLOWED_PREVIEW_HOSTS: "other.example" };
|
|
374
|
+
expect(isDraftHostAllowed("other.example", env)).toBe(true);
|
|
375
|
+
expect(isDraftHostAllowed("als-storefront.deco.site", env)).toBe(true);
|
|
376
|
+
} finally {
|
|
377
|
+
setDecoSiteHost(null);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("a blank/null/undefined site name registers no host", () => {
|
|
382
|
+
// The binding passes DECO_SITE_NAME straight through — an unnamed site
|
|
383
|
+
// (unset binding, blank value) is not armed.
|
|
384
|
+
for (const site of [" ", null, undefined]) {
|
|
385
|
+
setDecoSiteHost(site);
|
|
386
|
+
try {
|
|
387
|
+
expect(isDraftPreviewEnabled({})).toBe(false);
|
|
388
|
+
} finally {
|
|
389
|
+
setDecoSiteHost(null);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("DECO_ALLOWED_PREVIEW_HOSTS=none kills the inferred hosts too", () => {
|
|
395
|
+
setDraftPreviewHosts(["fila.vtex.app"]);
|
|
396
|
+
setDecoSiteHost("als-storefront");
|
|
397
|
+
try {
|
|
398
|
+
// The kill switch wins over the inferred hosts AND the site block, so a
|
|
399
|
+
// bad rollout can be stopped without a deploy.
|
|
400
|
+
const env = { DECO_ALLOWED_PREVIEW_HOSTS: "none" };
|
|
401
|
+
expect(isDraftPreviewEnabled(env)).toBe(false);
|
|
402
|
+
expect(isDraftHostAllowed("als-storefront.deco.site", env)).toBe(false);
|
|
403
|
+
expect(isDraftHostAllowed("als-storefront.deco-cx.workers.dev", env)).toBe(false);
|
|
404
|
+
expect(isDraftHostAllowed("fila.vtex.app", env)).toBe(false);
|
|
405
|
+
} finally {
|
|
406
|
+
setDraftPreviewHosts([]);
|
|
407
|
+
setDecoSiteHost(null);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
326
412
|
describe("draftPointerFromRequest", () => {
|
|
327
413
|
it("reads the pointer from the __deco_draft cookie (in-preview navigation)", () => {
|
|
328
414
|
const req = new Request(
|
package/src/cms/draftSource.ts
CHANGED
|
@@ -162,7 +162,7 @@ export function previewApiOriginForHost(
|
|
|
162
162
|
// is invisible to the others. (The MIDDLEWARE runtime is a separate world
|
|
163
163
|
// even so — which is why the page-side gate is the authoritative one and the
|
|
164
164
|
// middleware only hard-gates when the env override is present.)
|
|
165
|
-
const G = globalThis as { __decoDraftHosts?: string[] };
|
|
165
|
+
const G = globalThis as { __decoDraftHosts?: string[]; __decoSite?: string };
|
|
166
166
|
|
|
167
167
|
/** Install the site-declared preview hosts. Called by the framework binding at setup. */
|
|
168
168
|
export function setDraftPreviewHosts(hosts: readonly unknown[]): void {
|
|
@@ -172,6 +172,32 @@ export function setDraftPreviewHosts(hosts: readonly unknown[]): void {
|
|
|
172
172
|
.filter(Boolean);
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Register the resolved site name (`DECO_SITE_NAME` / an explicit setup
|
|
177
|
+
* option, resolved by the framework binding at setup). deco-operated preview
|
|
178
|
+
* hosts are derived from it, both exact:
|
|
179
|
+
*
|
|
180
|
+
* - `<site>.deco.site` — the stable deco-hosted domain.
|
|
181
|
+
* - `<site>.deco-cx.workers.dev` — the workers.dev deploy URL
|
|
182
|
+
* (e.g. `casaevideo-tanstack.deco-cx.workers.dev`).
|
|
183
|
+
*
|
|
184
|
+
* Both are MERGED with the site-block/env list rather than replacing it: they
|
|
185
|
+
* are deco-operated infra, so a signed draft grant can preview there out of the
|
|
186
|
+
* box, while a custom production domain — never inferred here — stays inert.
|
|
187
|
+
* Fed from the trusted setup-time site name, never from the request or a draft.
|
|
188
|
+
*
|
|
189
|
+
* Threat model, now that a named site is no longer inert by default: the
|
|
190
|
+
* request host is spoofable on a direct-to-origin request (the edge is trusted
|
|
191
|
+
* to set `x-forwarded-host`), so for a named site the SIGNED `?__draft=` grant
|
|
192
|
+
* is the sole remaining gate — host-scoping only bounds blast radius. Set
|
|
193
|
+
* `DECO_ALLOWED_PREVIEW_HOSTS=none` to kill preview entirely, including these
|
|
194
|
+
* inferred hosts, without a deploy.
|
|
195
|
+
*/
|
|
196
|
+
export function setDecoSiteHost(site: string | null | undefined): void {
|
|
197
|
+
const s = (site ?? "").trim().toLowerCase();
|
|
198
|
+
G.__decoSite = s || undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
175
201
|
/**
|
|
176
202
|
* Hosts allowed to render drafts.
|
|
177
203
|
*
|
|
@@ -179,20 +205,35 @@ export function setDraftPreviewHosts(hosts: readonly unknown[]): void {
|
|
|
179
205
|
* reviewed in a PR, versioned with branches. `DECO_ALLOWED_PREVIEW_HOSTS`
|
|
180
206
|
* REPLACES it when set: an operational escape hatch (kill a bad value without
|
|
181
207
|
* a deploy, add a machine-specific port) — not the primary configuration.
|
|
208
|
+
*
|
|
209
|
+
* The deco-hosted domains inferred from the site name (via `setDecoSiteHost`)
|
|
210
|
+
* are always ADDED on top, so a signed draft grant can preview on
|
|
211
|
+
* deco-operated infra without any per-site config.
|
|
212
|
+
*
|
|
213
|
+
* The sentinel `DECO_ALLOWED_PREVIEW_HOSTS=none` is a KILL SWITCH: it disables
|
|
214
|
+
* preview entirely — the inferred hosts and the site block included — so a bad
|
|
215
|
+
* rollout can be stopped without a deploy. It must win over every other source.
|
|
182
216
|
*/
|
|
183
217
|
function readAllowedHosts(env: Record<string, string | undefined>): string[] {
|
|
184
218
|
const fromEnv = (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "")
|
|
185
219
|
.split(",")
|
|
186
220
|
.map((s) => s.trim().toLowerCase())
|
|
187
221
|
.filter(Boolean);
|
|
188
|
-
|
|
222
|
+
if (fromEnv.includes("none")) return [];
|
|
223
|
+
const configured = fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []);
|
|
224
|
+
const site = G.__decoSite;
|
|
225
|
+
if (!site) return configured;
|
|
226
|
+
const inferred = [`${site}.deco.site`, `${site}.deco-cx.workers.dev`].filter(
|
|
227
|
+
(h) => !configured.includes(h),
|
|
228
|
+
);
|
|
229
|
+
return inferred.length > 0 ? [...configured, ...inferred] : configured;
|
|
189
230
|
}
|
|
190
231
|
|
|
191
232
|
/**
|
|
192
233
|
* Whether `host` (as seen on the request) may render drafts.
|
|
193
234
|
*
|
|
194
|
-
* Compared against
|
|
195
|
-
*
|
|
235
|
+
* Compared against the allowlist verbatim, port included — local dev is
|
|
236
|
+
* `localhost:3100`, not `localhost`. The header is spoofable by a
|
|
196
237
|
* direct-to-origin request, but the draft id is the actual capability;
|
|
197
238
|
* host-scoping bounds blast radius (production domains stay inert), it is not
|
|
198
239
|
* a secret.
|
|
@@ -210,8 +251,11 @@ export function isDraftHostAllowed(
|
|
|
210
251
|
/**
|
|
211
252
|
* True when any host is allowed to preview. A plain env read — callers use it
|
|
212
253
|
* to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an
|
|
213
|
-
* unconfigured site never loses static/ISR rendering.
|
|
214
|
-
*
|
|
254
|
+
* unconfigured site never loses static/ISR rendering. A site with no config
|
|
255
|
+
* but a resolved name is now enabled here (its inferred `<site>.deco.site` /
|
|
256
|
+
* `<site>.deco-cx.workers.dev` hosts); `DECO_ALLOWED_PREVIEW_HOSTS=none`
|
|
257
|
+
* forces it back to fully inert. The per-request host match happens later, in
|
|
258
|
+
* `isDraftHostAllowed`.
|
|
215
259
|
*/
|
|
216
260
|
export function isDraftPreviewEnabled(
|
|
217
261
|
env?: Record<string, string | undefined>,
|
|
@@ -273,7 +317,7 @@ export async function resolveDraftDecofile(
|
|
|
273
317
|
options: ResolveDraftOptions,
|
|
274
318
|
): Promise<Record<string, unknown> | null> {
|
|
275
319
|
const env = envOrProcess(options.env);
|
|
276
|
-
if (
|
|
320
|
+
if (!isDraftPreviewEnabled(env)) return null;
|
|
277
321
|
|
|
278
322
|
const parsed = parseDraftPointer(options.pointer);
|
|
279
323
|
if (!parsed) return null;
|
|
@@ -389,7 +433,7 @@ export async function resolveDraftForRequest(
|
|
|
389
433
|
options: ResolveDraftForRequestOptions = {},
|
|
390
434
|
): Promise<Record<string, unknown> | null> {
|
|
391
435
|
const env = envOrProcess(options.env);
|
|
392
|
-
if (
|
|
436
|
+
if (!isDraftPreviewEnabled(env)) return null;
|
|
393
437
|
const pointer = draftPointerFromRequest(request);
|
|
394
438
|
if (!pointer) return null;
|
|
395
439
|
const host =
|
package/src/cms/index.ts
CHANGED
package/src/hooks/Image.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getImageQuality,
|
|
4
|
+
getOptimizedMediaUrl,
|
|
5
|
+
getSrcSet,
|
|
6
|
+
type ImageQuality,
|
|
7
|
+
registerImageQuality,
|
|
8
|
+
} from "./Image";
|
|
3
9
|
|
|
4
10
|
describe("getOptimizedMediaUrl", () => {
|
|
5
11
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
@@ -98,3 +104,82 @@ describe("getSrcSet", () => {
|
|
|
98
104
|
expect(result).toContain("foo.jpg");
|
|
99
105
|
});
|
|
100
106
|
});
|
|
107
|
+
|
|
108
|
+
describe("registerImageQuality", () => {
|
|
109
|
+
// Module-level setter, so every test has to put it back or it leaks into
|
|
110
|
+
// the rest of the file.
|
|
111
|
+
afterEach(() => {
|
|
112
|
+
registerImageQuality(undefined);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("emits no quality param by default", () => {
|
|
116
|
+
// The guarantee that makes this safe to land: every site that does not
|
|
117
|
+
// opt in keeps byte-identical URLs, so no CDN cache is invalidated.
|
|
118
|
+
expect(getImageQuality()).toBeUndefined();
|
|
119
|
+
const result = getOptimizedMediaUrl({
|
|
120
|
+
originalSrc: "https://cdn.example.com/foo.jpg",
|
|
121
|
+
width: 200,
|
|
122
|
+
fit: "cover",
|
|
123
|
+
});
|
|
124
|
+
expect(result).not.toContain("quality");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("emits the registered quality for CDN-routed images", () => {
|
|
128
|
+
registerImageQuality("high");
|
|
129
|
+
const result = getOptimizedMediaUrl({
|
|
130
|
+
originalSrc: "https://cdn.example.com/foo.jpg",
|
|
131
|
+
width: 200,
|
|
132
|
+
fit: "cover",
|
|
133
|
+
});
|
|
134
|
+
expect(result).toContain("quality=high");
|
|
135
|
+
});
|
|
136
|
+
it("pins the exact URL, param order included", () => {
|
|
137
|
+
// Param ORDER is part of the CDN cache key, and `toContain` cannot see
|
|
138
|
+
// it: reordering the params would keep every other assertion here green
|
|
139
|
+
// while cold-caching every image on every site. It is also the property
|
|
140
|
+
// that lets a site swap its node_modules patch for this setter without a
|
|
141
|
+
// cache flush, so it needs a real equality check.
|
|
142
|
+
registerImageQuality("high");
|
|
143
|
+
expect(
|
|
144
|
+
getOptimizedMediaUrl({
|
|
145
|
+
originalSrc: "https://cdn.example.com/foo.jpg",
|
|
146
|
+
width: 200,
|
|
147
|
+
height: 300,
|
|
148
|
+
fit: "cover",
|
|
149
|
+
}),
|
|
150
|
+
).toBe(
|
|
151
|
+
"https://decoims.com/image?fit=cover&width=200&height=300&quality=high&src=https://cdn.example.com/foo.jpg",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("carries the quality into every srcset entry", () => {
|
|
156
|
+
registerImageQuality("high");
|
|
157
|
+
const result = getSrcSet("https://cdn.example.com/foo.jpg", 100);
|
|
158
|
+
const entries = result?.split(", ") ?? [];
|
|
159
|
+
expect(entries.length).toBeGreaterThan(1);
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
expect(entry).toContain("quality=high");
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("leaves VTEX sources alone — they resize via their own path syntax", () => {
|
|
166
|
+
registerImageQuality("high");
|
|
167
|
+
const result = getOptimizedMediaUrl({
|
|
168
|
+
originalSrc:
|
|
169
|
+
"https://acme.vtexassets.com/arquivos/ids/123456/product.jpg?v=1",
|
|
170
|
+
width: 200,
|
|
171
|
+
height: 300,
|
|
172
|
+
fit: "cover",
|
|
173
|
+
});
|
|
174
|
+
expect(result).toContain("/arquivos/ids/123456-200-300/");
|
|
175
|
+
expect(result).not.toContain("quality");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("treats an empty string as unset, for untyped callers", () => {
|
|
179
|
+
// Unreachable from TypeScript now that the parameter is a union, but a
|
|
180
|
+
// value read from env or CMS config arrives as a plain string, so the
|
|
181
|
+
// runtime guard still earns its keep.
|
|
182
|
+
registerImageQuality("" as unknown as ImageQuality);
|
|
183
|
+
expect(getImageQuality()).toBeUndefined();
|
|
184
|
+
});
|
|
185
|
+
});
|
package/src/hooks/Image.tsx
CHANGED
|
@@ -31,6 +31,61 @@ export function getImageCdnDomain(): string {
|
|
|
31
31
|
return imageCdnDomain;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// -------------------------------------------------------------------------
|
|
35
|
+
// Configurable image quality
|
|
36
|
+
// -------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Quality levels the Deco image CDN accepts as a site-wide default.
|
|
40
|
+
*
|
|
41
|
+
* The CDN maps these to 60% / 70% / 80%. It also accepts `original` (100%),
|
|
42
|
+
* deliberately excluded here: a global 100% default hurts performance on
|
|
43
|
+
* every page, which is the same call `DefaultQualityOptions` makes in the
|
|
44
|
+
* Fresh implementation this file was ported from (deco-cx/apps,
|
|
45
|
+
* website/components/Image.tsx).
|
|
46
|
+
*
|
|
47
|
+
* A union rather than `string` because the CDN **fails silently** on
|
|
48
|
+
* anything else: it answers 200 and serves its 60% default. So the obvious
|
|
49
|
+
* `registerImageQuality("80")` — quality means 1-100 in Cloudflare Images,
|
|
50
|
+
* next/image and imgix — would quietly serve the LOWEST quality while
|
|
51
|
+
* changing the cache key on every image. Measured against production
|
|
52
|
+
* decoims.com on one asset: no param 2911 B, `low` 2911, `medium` 3235,
|
|
53
|
+
* `high` 5189, and `"80"` / `"HIGH"` / garbage all 2911.
|
|
54
|
+
*/
|
|
55
|
+
export type ImageQuality = "low" | "medium" | "high";
|
|
56
|
+
|
|
57
|
+
let imageQuality: ImageQuality | undefined;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Register the quality level `getOptimizedMediaUrl` asks the image CDN for.
|
|
61
|
+
*
|
|
62
|
+
* Call once at module scope in your site's setup, NOT from a loader, action or
|
|
63
|
+
* anything else on the request path. This is module-level state shared by every
|
|
64
|
+
* request in a Worker isolate: setting it per-request would leak across
|
|
65
|
+
* concurrent requests, and setting it on only one of the SSR/hydration paths
|
|
66
|
+
* would produce mismatched `src`/`srcSet` and re-download every image.
|
|
67
|
+
*
|
|
68
|
+
* Unset by default, which emits no `quality` param and leaves the CDN on its
|
|
69
|
+
* 60% default — so existing sites are byte-for-byte unaffected and no CDN
|
|
70
|
+
* cache is invalidated. Set it when a site's art direction needs fidelity over
|
|
71
|
+
* bytes: on fashion/editorial catalogues the default compression visibly
|
|
72
|
+
* softens fabric texture and print detail.
|
|
73
|
+
*
|
|
74
|
+
* Applies to every URL built by `getOptimizedMediaUrl` — `Image`, `getSrcSet`
|
|
75
|
+
* and also `Video` when it is given `forceOptimizedSrc`. It does NOT apply to
|
|
76
|
+
* VTEX- or Shopify-hosted sources: those are resized through their own native
|
|
77
|
+
* URL syntax (`optimizeVTEX` / `optimizeShopify`), which returns before the
|
|
78
|
+
* query-param block. On a VTEX storefront that means product imagery is
|
|
79
|
+
* untouched and only CMS/banner assets are affected.
|
|
80
|
+
*/
|
|
81
|
+
export function registerImageQuality(quality: ImageQuality | undefined) {
|
|
82
|
+
imageQuality = quality || undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getImageQuality(): ImageQuality | undefined {
|
|
86
|
+
return imageQuality;
|
|
87
|
+
}
|
|
88
|
+
|
|
34
89
|
// -------------------------------------------------------------------------
|
|
35
90
|
// Fit options & optimization types
|
|
36
91
|
// -------------------------------------------------------------------------
|
|
@@ -126,6 +181,7 @@ export function getOptimizedMediaUrl(opts: OptimizationOptions): string {
|
|
|
126
181
|
params.set("fit", fit);
|
|
127
182
|
params.set("width", `${width}`);
|
|
128
183
|
if (height) params.set("height", `${height}`);
|
|
184
|
+
if (imageQuality) params.set("quality", imageQuality);
|
|
129
185
|
|
|
130
186
|
return `https://${imageCdnDomain}/image?${params}&src=${imageSource}`;
|
|
131
187
|
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -8,10 +8,13 @@ export {
|
|
|
8
8
|
default as Image,
|
|
9
9
|
registerImageCdnDomain,
|
|
10
10
|
getImageCdnDomain,
|
|
11
|
+
registerImageQuality,
|
|
12
|
+
getImageQuality,
|
|
11
13
|
getOptimizedMediaUrl,
|
|
12
14
|
getSrcSet,
|
|
13
15
|
FACTORS,
|
|
14
16
|
type ImageProps,
|
|
17
|
+
type ImageQuality,
|
|
15
18
|
type FitOptions,
|
|
16
19
|
} from "./Image";
|
|
17
20
|
export { Picture, Source, type PictureProps, type SourceProps } from "./Picture";
|