@c9up/nebula 0.1.6 → 0.1.7
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/dist/atoms/Image.d.ts +106 -0
- package/dist/atoms/Image.js +139 -0
- package/dist/atoms/index.d.ts +1 -0
- package/dist/atoms/index.js +1 -0
- package/dist/lib/image.d.ts +213 -0
- package/dist/lib/image.js +331 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/molecules/Picture.d.ts +46 -0
- package/dist/molecules/Picture.js +62 -0
- package/dist/molecules/index.d.ts +1 -0
- package/dist/molecules/index.js +1 -0
- package/nebula.css +1 -1
- package/package.json +1 -1
- package/registry.json +25 -0
- package/src/atoms/Image.ts +226 -0
- package/src/atoms/index.ts +10 -0
- package/src/lib/image.ts +470 -0
- package/src/lib/index.ts +25 -0
- package/src/molecules/Picture.ts +92 -0
- package/src/molecules/index.ts +1 -0
package/src/lib/image.ts
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responsive image plumbing — the arithmetic behind `Image` and `Picture`.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is pure: widths in, strings out. It lives apart from the
|
|
5
|
+
* components because the interesting part of a responsive image is not the
|
|
6
|
+
* markup, it is deciding *which* variants to offer the browser, and that
|
|
7
|
+
* decision is worth testing without a DOM.
|
|
8
|
+
*
|
|
9
|
+
* **Where the bytes come from is not nebula's business.** A component that
|
|
10
|
+
* imported an image processor would drag a native module into a package whose
|
|
11
|
+
* only peer is Aurora, and would pin every app to one way of serving images.
|
|
12
|
+
* So the URL is produced by a resolver the app installs once
|
|
13
|
+
* ({@link setImageResolver}). The default one points at `/__image`, the
|
|
14
|
+
* endpoint `@c9up/prism` registers; pointing it at a CDN instead is a
|
|
15
|
+
* one-line change and no component notices.
|
|
16
|
+
*
|
|
17
|
+
* The resolver asks for a width, a format and a quality — never a height and
|
|
18
|
+
* never a crop. Those three axes are the ones the server can safely allow-list,
|
|
19
|
+
* which is what keeps an open transformation endpoint from becoming a cache
|
|
20
|
+
* bomb. Cropping is `object-fit` on the element, where it costs nothing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How an image responds to the width of its container.
|
|
25
|
+
*
|
|
26
|
+
* - `constrained` — scales down to fit, never past its declared width. The
|
|
27
|
+
* default, and what almost every image in a page wants.
|
|
28
|
+
* - `full-width` — always the width of its container. Hero images, banners.
|
|
29
|
+
* - `fixed` — the declared size, whatever the viewport. Logos, avatars,
|
|
30
|
+
* icons. Still offers a 2x variant for dense screens.
|
|
31
|
+
* - `none` — no `srcset`, no `sizes`. An escape hatch for when the markup is
|
|
32
|
+
* being assembled by something else.
|
|
33
|
+
*/
|
|
34
|
+
export type ImageLayout = "constrained" | "full-width" | "fixed" | "none";
|
|
35
|
+
|
|
36
|
+
/** Output formats an image endpoint is expected to understand. */
|
|
37
|
+
export type ImageFormat = "avif" | "webp" | "jpeg" | "png";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How the rendered box reconciles a different aspect ratio.
|
|
41
|
+
*
|
|
42
|
+
* These are the CSS `object-fit` values, and they are applied as CSS — the
|
|
43
|
+
* image is never cropped server-side. A crop asked for through a URL is an
|
|
44
|
+
* unbounded axis on a public endpoint, and the same result is one class here.
|
|
45
|
+
*/
|
|
46
|
+
export type ImageFit = "cover" | "contain" | "fill" | "none" | "scale-down";
|
|
47
|
+
|
|
48
|
+
/** Where the visible part sits when `fit` crops. CSS `object-position`. */
|
|
49
|
+
export type ImagePosition =
|
|
50
|
+
| "center"
|
|
51
|
+
| "top"
|
|
52
|
+
| "bottom"
|
|
53
|
+
| "left"
|
|
54
|
+
| "right"
|
|
55
|
+
| "top left"
|
|
56
|
+
| "top right"
|
|
57
|
+
| "bottom left"
|
|
58
|
+
| "bottom right";
|
|
59
|
+
|
|
60
|
+
/** What a resolver is asked to produce one URL for. */
|
|
61
|
+
export interface ImageTransform {
|
|
62
|
+
/** Target width in pixels. The server must not upscale past the source. */
|
|
63
|
+
width: number;
|
|
64
|
+
format?: ImageFormat;
|
|
65
|
+
/** 1-100. The endpoint decides whether a given value is allowed. */
|
|
66
|
+
quality?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Turns a source and a transform into the URL to put in a `srcset`. */
|
|
70
|
+
export type ImageUrlResolver = (
|
|
71
|
+
src: string,
|
|
72
|
+
transform: ImageTransform,
|
|
73
|
+
) => string;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Widths for elements that are not the width of a screen.
|
|
77
|
+
*
|
|
78
|
+
* Avatars, icons, thumbnails. Without them a 48px avatar's nearest offer is
|
|
79
|
+
* 640 — thirteen times the pixels it can show — because every other ladder
|
|
80
|
+
* starts at a phone's viewport.
|
|
81
|
+
*/
|
|
82
|
+
export const IMAGE_SIZES: readonly number[] = [
|
|
83
|
+
16, 32, 48, 64, 96, 128, 256, 384,
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The widths worth generating, from real device pixel counts.
|
|
88
|
+
*
|
|
89
|
+
* Astro's ladder, close to the one Next.js settled on independently — which is
|
|
90
|
+
* the reason to reuse it rather than invent one. They are device widths, not
|
|
91
|
+
* round numbers: 828 is the iPhone XR, 1668 is an iPad. A tidier
|
|
92
|
+
* `[400, 800, 1200, 1600]` misses every one of them and ships each device a
|
|
93
|
+
* slightly-too-large image.
|
|
94
|
+
*
|
|
95
|
+
* Density is what makes the ladder cheap to round up to. The nearest rung
|
|
96
|
+
* above 2400 here is 2560, six percent over; on a sparser ladder it would be
|
|
97
|
+
* 3840.
|
|
98
|
+
*/
|
|
99
|
+
export const DEFAULT_RESOLUTIONS: readonly number[] = [
|
|
100
|
+
640, // older and lower-end phones
|
|
101
|
+
750, // iPhone 6-8
|
|
102
|
+
828, // iPhone XR/11
|
|
103
|
+
960, // older horizontal phones
|
|
104
|
+
1080, // iPhone 6-8 Plus
|
|
105
|
+
1280, // 720p
|
|
106
|
+
1668, // various iPads
|
|
107
|
+
1920, // 1080p
|
|
108
|
+
2048, // QXGA
|
|
109
|
+
2560, // WQXGA
|
|
110
|
+
3200, // QHD+
|
|
111
|
+
3840, // 4K
|
|
112
|
+
4480, // 4.5K
|
|
113
|
+
5120, // 5K
|
|
114
|
+
6016, // 6K
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The ladder without the sizes only a desktop display asks for.
|
|
119
|
+
*
|
|
120
|
+
* Worth choosing deliberately for a full-width image: the top of
|
|
121
|
+
* {@link DEFAULT_RESOLUTIONS} is eleven extra variants to store and warm, for
|
|
122
|
+
* screens most sites see rarely.
|
|
123
|
+
*/
|
|
124
|
+
export const LIMITED_RESOLUTIONS: readonly number[] = [
|
|
125
|
+
640, 750, 828, 1080, 1280, 1668, 2048, 2560,
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Every width a component may ask for, ascending.
|
|
130
|
+
*
|
|
131
|
+
* This list and the endpoint's allow-list are the same list, and that is the
|
|
132
|
+
* contract between the two halves: a component that emits a width the endpoint
|
|
133
|
+
* does not serve produces a `srcset` where every entry is a 400, and the page
|
|
134
|
+
* silently falls back to the one `src`.
|
|
135
|
+
*
|
|
136
|
+
* It is also why a declared width is never emitted literally. `width: 1200`
|
|
137
|
+
* asking for 1200 and 2400 would be two widths no allow-list contains, and
|
|
138
|
+
* allow-listing whatever an author happens to type is not an allow-list.
|
|
139
|
+
*/
|
|
140
|
+
export function allSizes(breakpoints: readonly number[]): number[] {
|
|
141
|
+
return [...new Set([...IMAGE_SIZES, ...breakpoints])].sort((a, b) => a - b);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The smallest offered width that still covers `target`.
|
|
146
|
+
*
|
|
147
|
+
* Upwards, never downwards. Rounding down is how a 2x screen is handed an
|
|
148
|
+
* image with fewer pixels than it can show, and the result is soft in a way
|
|
149
|
+
* that is hard to attribute later. Falls back to the largest rung, which the
|
|
150
|
+
* endpoint clamps to the source anyway.
|
|
151
|
+
*/
|
|
152
|
+
function snapUp(target: number, sizes: readonly number[]): number {
|
|
153
|
+
return sizes.find((size) => size >= target) ?? sizes[sizes.length - 1] ?? 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The width to actually request for a declared one.
|
|
158
|
+
*
|
|
159
|
+
* The `src` attribute and a density entry are single URLs rather than ladders,
|
|
160
|
+
* and they are subject to the same contract: a literal declared width is a
|
|
161
|
+
* width no allow-list contains.
|
|
162
|
+
*/
|
|
163
|
+
export function offeredWidth(
|
|
164
|
+
width: number,
|
|
165
|
+
breakpoints: readonly number[] = DEFAULT_RESOLUTIONS,
|
|
166
|
+
): number {
|
|
167
|
+
return snapUp(width, allSizes(breakpoints));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface WidthsOptions {
|
|
171
|
+
/** The declared display width. Required by every layout but `full-width`. */
|
|
172
|
+
width?: number;
|
|
173
|
+
layout: ImageLayout;
|
|
174
|
+
/** Ladder to draw from. Defaults to {@link DEFAULT_RESOLUTIONS}. */
|
|
175
|
+
breakpoints?: readonly number[];
|
|
176
|
+
/**
|
|
177
|
+
* The source's own width, when it is known.
|
|
178
|
+
*
|
|
179
|
+
* Only a refinement: the endpoint must refuse to upscale anyway, so
|
|
180
|
+
* leaving it out costs a duplicate URL that serves the same bytes, never a
|
|
181
|
+
* blurry enlargement. Pass it and those duplicates disappear.
|
|
182
|
+
*/
|
|
183
|
+
originalWidth?: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The widths to offer, ascending.
|
|
188
|
+
*
|
|
189
|
+
* Every value comes off {@link allSizes} — see there for why a declared width
|
|
190
|
+
* is never emitted literally. 1x and 2x are always represented, rounded up, so
|
|
191
|
+
* the common case of a dense screen at the declared width is never served
|
|
192
|
+
* fewer pixels than it can show.
|
|
193
|
+
*/
|
|
194
|
+
export function imageWidths(options: WidthsOptions): number[] {
|
|
195
|
+
const {
|
|
196
|
+
width,
|
|
197
|
+
layout,
|
|
198
|
+
breakpoints = DEFAULT_RESOLUTIONS,
|
|
199
|
+
originalWidth,
|
|
200
|
+
} = options;
|
|
201
|
+
|
|
202
|
+
const every = allSizes(breakpoints);
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Drop what the source cannot fill, but never return nothing.
|
|
206
|
+
*
|
|
207
|
+
* A source smaller than every rung still has to be offered at some width,
|
|
208
|
+
* and the endpoint clamps to the source rather than enlarging it.
|
|
209
|
+
*/
|
|
210
|
+
const withinSource = (candidates: number[]): number[] => {
|
|
211
|
+
if (originalWidth === undefined) return candidates;
|
|
212
|
+
const kept = candidates.filter((candidate) => candidate <= originalWidth);
|
|
213
|
+
if (kept.length > 0) return kept;
|
|
214
|
+
const smallest = candidates[0];
|
|
215
|
+
return smallest === undefined ? [] : [smallest];
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (layout === "full-width") {
|
|
219
|
+
return withinSource([...breakpoints]);
|
|
220
|
+
}
|
|
221
|
+
if (width === undefined || width <= 0) return [];
|
|
222
|
+
|
|
223
|
+
const oneX = snapUp(width, every);
|
|
224
|
+
const twoX = snapUp(width * 2, every);
|
|
225
|
+
|
|
226
|
+
if (layout === "fixed") {
|
|
227
|
+
return withinSource([...new Set([oneX, twoX])].sort((a, b) => a - b));
|
|
228
|
+
}
|
|
229
|
+
if (layout === "constrained") {
|
|
230
|
+
// Below its declared width a constrained image is the width of the
|
|
231
|
+
// viewport, so the viewport rungs under the cap earn their place. The
|
|
232
|
+
// floor is the smallest device width: nothing narrower is a screen,
|
|
233
|
+
// and offering 32w to an image that is at least 640 wide is noise in
|
|
234
|
+
// every `srcset` on the page.
|
|
235
|
+
const floor = breakpoints[0] ?? 0;
|
|
236
|
+
const intermediate = every.filter((size) => size >= floor && size <= twoX);
|
|
237
|
+
return withinSource(
|
|
238
|
+
[...new Set([oneX, twoX, ...intermediate])].sort((a, b) => a - b),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The `sizes` attribute for a layout.
|
|
246
|
+
*
|
|
247
|
+
* Without it the browser assumes the image is the full width of the viewport
|
|
248
|
+
* and picks the largest variant on every screen — which is the failure mode
|
|
249
|
+
* where adding a `srcset` makes a page slower, not faster.
|
|
250
|
+
*/
|
|
251
|
+
export function imageSizes(options: {
|
|
252
|
+
width?: number;
|
|
253
|
+
layout: ImageLayout;
|
|
254
|
+
}): string | undefined {
|
|
255
|
+
const { width, layout } = options;
|
|
256
|
+
if (width === undefined || width <= 0) {
|
|
257
|
+
return layout === "full-width" ? "100vw" : undefined;
|
|
258
|
+
}
|
|
259
|
+
switch (layout) {
|
|
260
|
+
case "constrained":
|
|
261
|
+
// Wider screen than the cap: the image stops at the cap. Narrower:
|
|
262
|
+
// it is the screen.
|
|
263
|
+
return `(min-width: ${width}px) ${width}px, 100vw`;
|
|
264
|
+
case "fixed":
|
|
265
|
+
return `${width}px`;
|
|
266
|
+
case "full-width":
|
|
267
|
+
return "100vw";
|
|
268
|
+
default:
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface SrcSetOptions {
|
|
274
|
+
src: string;
|
|
275
|
+
widths: readonly number[];
|
|
276
|
+
format?: ImageFormat;
|
|
277
|
+
quality?: number;
|
|
278
|
+
/** Defaults to the installed resolver. */
|
|
279
|
+
resolve?: ImageUrlResolver;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A `srcset` with `w` descriptors, or `undefined` when there is nothing to offer. */
|
|
283
|
+
export function imageSrcSet(options: SrcSetOptions): string | undefined {
|
|
284
|
+
const { src, widths, format, quality, resolve = imageUrl } = options;
|
|
285
|
+
if (widths.length === 0) return undefined;
|
|
286
|
+
return widths
|
|
287
|
+
.map((width) => `${resolve(src, { width, format, quality })} ${width}w`)
|
|
288
|
+
.join(", ");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface DensitySrcSetOptions {
|
|
292
|
+
src: string;
|
|
293
|
+
/** The width at 1x. */
|
|
294
|
+
width: number;
|
|
295
|
+
/** Pixel ratios to serve, e.g. `[1, 2]`. */
|
|
296
|
+
densities: readonly number[];
|
|
297
|
+
format?: ImageFormat;
|
|
298
|
+
quality?: number;
|
|
299
|
+
originalWidth?: number;
|
|
300
|
+
breakpoints?: readonly number[];
|
|
301
|
+
resolve?: ImageUrlResolver;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A `srcset` with `x` descriptors.
|
|
306
|
+
*
|
|
307
|
+
* The alternative to widths, never a companion to them: a `srcset` mixing `w`
|
|
308
|
+
* and `x` descriptors is invalid, and browsers handle the mixture by ignoring
|
|
309
|
+
* the whole attribute.
|
|
310
|
+
*/
|
|
311
|
+
export function densitySrcSet(
|
|
312
|
+
options: DensitySrcSetOptions,
|
|
313
|
+
): string | undefined {
|
|
314
|
+
const {
|
|
315
|
+
src,
|
|
316
|
+
width,
|
|
317
|
+
densities,
|
|
318
|
+
format,
|
|
319
|
+
quality,
|
|
320
|
+
originalWidth,
|
|
321
|
+
breakpoints = DEFAULT_RESOLUTIONS,
|
|
322
|
+
resolve = imageUrl,
|
|
323
|
+
} = options;
|
|
324
|
+
if (width <= 0 || densities.length === 0) return undefined;
|
|
325
|
+
|
|
326
|
+
const seen = new Set<number>();
|
|
327
|
+
const entries: string[] = [];
|
|
328
|
+
for (const density of [...densities].sort((a, b) => a - b)) {
|
|
329
|
+
if (density <= 0) continue;
|
|
330
|
+
const target = offeredWidth(Math.round(width * density), breakpoints);
|
|
331
|
+
if (originalWidth !== undefined && target > originalWidth) continue;
|
|
332
|
+
if (seen.has(target)) continue;
|
|
333
|
+
seen.add(target);
|
|
334
|
+
entries.push(
|
|
335
|
+
`${resolve(src, { width: target, format, quality })} ${density}x`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return entries.length === 0 ? undefined : entries.join(", ");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Where the default resolver sends its requests.
|
|
343
|
+
*
|
|
344
|
+
* `__`-prefixed because it is the framework's route rather than the
|
|
345
|
+
* application's, the same convention as `__assets` and `__relay`.
|
|
346
|
+
*/
|
|
347
|
+
let endpoint = "/__image";
|
|
348
|
+
|
|
349
|
+
/** Point the built-in resolver at a different path. */
|
|
350
|
+
export function setImageEndpoint(path: string): void {
|
|
351
|
+
endpoint = path;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The resolver used when an application installs none.
|
|
356
|
+
*
|
|
357
|
+
* Parameter names are one letter because they end up in every `srcset` entry
|
|
358
|
+
* of every image on the page, and a `srcset` with fifteen entries repeats them
|
|
359
|
+
* fifteen times.
|
|
360
|
+
*/
|
|
361
|
+
export const defaultImageResolver: ImageUrlResolver = (src, transform) => {
|
|
362
|
+
const params = new URLSearchParams({
|
|
363
|
+
src,
|
|
364
|
+
w: String(Math.round(transform.width)),
|
|
365
|
+
});
|
|
366
|
+
if (transform.format !== undefined) params.set("f", transform.format);
|
|
367
|
+
if (transform.quality !== undefined) {
|
|
368
|
+
params.set("q", String(Math.round(transform.quality)));
|
|
369
|
+
}
|
|
370
|
+
return `${endpoint}?${params.toString()}`;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
let resolver: ImageUrlResolver = defaultImageResolver;
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Install the resolver every image uses.
|
|
377
|
+
*
|
|
378
|
+
* Call it once at startup — from the app's entry on the client, and from the
|
|
379
|
+
* same module on the server so SSR and hydration agree. They must agree: an
|
|
380
|
+
* `src` that differs between the two makes the browser discard the image the
|
|
381
|
+
* server already started fetching and request another.
|
|
382
|
+
*
|
|
383
|
+
* Passing `null` restores {@link defaultImageResolver}.
|
|
384
|
+
*/
|
|
385
|
+
export function setImageResolver(next: ImageUrlResolver | null): void {
|
|
386
|
+
resolver = next ?? defaultImageResolver;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The resolver currently installed. */
|
|
390
|
+
export function getImageResolver(): ImageUrlResolver {
|
|
391
|
+
return resolver;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Build one image URL through the installed resolver. */
|
|
395
|
+
export function imageUrl(src: string, transform: ImageTransform): string {
|
|
396
|
+
return resolver(src, transform);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Tailwind classes for a layout. Height is always `auto` so the ratio holds. */
|
|
400
|
+
export function layoutClasses(layout: ImageLayout): string {
|
|
401
|
+
switch (layout) {
|
|
402
|
+
case "full-width":
|
|
403
|
+
return "h-auto w-full";
|
|
404
|
+
case "constrained":
|
|
405
|
+
return "h-auto max-w-full";
|
|
406
|
+
default:
|
|
407
|
+
return "h-auto";
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const FIT_CLASSES: Readonly<Record<ImageFit, string>> = {
|
|
412
|
+
cover: "object-cover",
|
|
413
|
+
contain: "object-contain",
|
|
414
|
+
fill: "object-fill",
|
|
415
|
+
none: "object-none",
|
|
416
|
+
"scale-down": "object-scale-down",
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const POSITION_CLASSES: Readonly<Record<ImagePosition, string>> = {
|
|
420
|
+
center: "object-center",
|
|
421
|
+
top: "object-top",
|
|
422
|
+
bottom: "object-bottom",
|
|
423
|
+
left: "object-left",
|
|
424
|
+
right: "object-right",
|
|
425
|
+
"top left": "object-left-top",
|
|
426
|
+
"top right": "object-right-top",
|
|
427
|
+
"bottom left": "object-left-bottom",
|
|
428
|
+
"bottom right": "object-right-bottom",
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
/** The `object-fit` class for a fit value. */
|
|
432
|
+
export function fitClass(fit: ImageFit | undefined): string | undefined {
|
|
433
|
+
return fit === undefined ? undefined : FIT_CLASSES[fit];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The `object-position` class for a position value. */
|
|
437
|
+
export function positionClass(
|
|
438
|
+
position: ImagePosition | undefined,
|
|
439
|
+
): string | undefined {
|
|
440
|
+
return position === undefined ? undefined : POSITION_CLASSES[position];
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The MIME type a `<source type>` needs for a format. */
|
|
444
|
+
export function mimeType(format: ImageFormat): string {
|
|
445
|
+
return `image/${format}`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const EXTENSIONS: Readonly<Record<string, ImageFormat>> = {
|
|
449
|
+
avif: "avif",
|
|
450
|
+
jpeg: "jpeg",
|
|
451
|
+
jpg: "jpeg",
|
|
452
|
+
png: "png",
|
|
453
|
+
webp: "webp",
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The format a source's file name claims, if any.
|
|
458
|
+
*
|
|
459
|
+
* Used to pick the fallback of a `<picture>`: converting a PNG with
|
|
460
|
+
* transparency to JPEG puts a black box behind it, so the fallback follows the
|
|
461
|
+
* source rather than a fixed default. The extension is a claim, not proof —
|
|
462
|
+
* only the server knows what the bytes really are, and it re-reads them.
|
|
463
|
+
*/
|
|
464
|
+
export function formatFromSource(src: string): ImageFormat | undefined {
|
|
465
|
+
// Cut the query and fragment first, or `photo.png?v=2` has no extension.
|
|
466
|
+
const path = src.split(/[?#]/, 1)[0] ?? "";
|
|
467
|
+
const dot = path.lastIndexOf(".");
|
|
468
|
+
if (dot === -1) return undefined;
|
|
469
|
+
return EXTENSIONS[path.slice(dot + 1).toLowerCase()];
|
|
470
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -18,4 +18,29 @@ export {
|
|
|
18
18
|
type VariantShape,
|
|
19
19
|
} from "./cva.js";
|
|
20
20
|
export { byId, resetIds, uid } from "./id.js";
|
|
21
|
+
export {
|
|
22
|
+
allSizes,
|
|
23
|
+
DEFAULT_RESOLUTIONS,
|
|
24
|
+
defaultImageResolver,
|
|
25
|
+
densitySrcSet,
|
|
26
|
+
fitClass,
|
|
27
|
+
formatFromSource,
|
|
28
|
+
getImageResolver,
|
|
29
|
+
type ImageFit,
|
|
30
|
+
type ImageFormat,
|
|
31
|
+
type ImageLayout,
|
|
32
|
+
type ImagePosition,
|
|
33
|
+
type ImageTransform,
|
|
34
|
+
type ImageUrlResolver,
|
|
35
|
+
imageSizes,
|
|
36
|
+
imageSrcSet,
|
|
37
|
+
imageUrl,
|
|
38
|
+
imageWidths,
|
|
39
|
+
LIMITED_RESOLUTIONS,
|
|
40
|
+
layoutClasses,
|
|
41
|
+
mimeType,
|
|
42
|
+
positionClass,
|
|
43
|
+
setImageEndpoint,
|
|
44
|
+
setImageResolver,
|
|
45
|
+
} from "./image.js";
|
|
21
46
|
export { accessor, callHandler, type Reactive, read, readOr } from "./props.js";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picture — the same image in several formats, browser picks the first it can.
|
|
3
|
+
*
|
|
4
|
+
* `Image` asks for one format and trusts it to be supported. That is fine for
|
|
5
|
+
* WebP, which every current browser reads. It is not fine for AVIF, which is
|
|
6
|
+
* 20-30% smaller again and still not universal — so AVIF can only be offered
|
|
7
|
+
* *alongside* a fallback, which is what `<picture>` is for.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* Picture({ src: "/photos/hero.jpg", alt: "…", width: 1200, height: 800 })
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* renders a `<source type="image/avif">`, a `<source type="image/webp">` and
|
|
14
|
+
* the original as the `<img>` underneath. The browser takes the first `type`
|
|
15
|
+
* it understands; one that understands none takes the `<img>`, which is also
|
|
16
|
+
* what a crawler reads.
|
|
17
|
+
*
|
|
18
|
+
* The `<img>` is `Image` itself, with every prop forwarded. That is what makes
|
|
19
|
+
* this a molecule rather than an atom, and it is deliberate: the `srcset`
|
|
20
|
+
* arithmetic, the `sizes`, the loading attributes and the layout classes have
|
|
21
|
+
* exactly one implementation, so a `<source>` can never end up offering a
|
|
22
|
+
* different ladder than the `<img>` beneath it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { component, html } from "@c9up/aurora";
|
|
26
|
+
import {
|
|
27
|
+
Image,
|
|
28
|
+
type ImageProps,
|
|
29
|
+
resolvedSizes,
|
|
30
|
+
resolvedSrcSet,
|
|
31
|
+
} from "../atoms/Image.js";
|
|
32
|
+
import { formatFromSource, type ImageFormat, mimeType } from "../lib/image.js";
|
|
33
|
+
import { type Reactive, read, readOr } from "../lib/props.js";
|
|
34
|
+
|
|
35
|
+
export interface PictureProps extends ImageProps {
|
|
36
|
+
/**
|
|
37
|
+
* Formats to offer above the fallback, best first.
|
|
38
|
+
*
|
|
39
|
+
* Order is the whole contract: a browser takes the first `type` it
|
|
40
|
+
* supports without comparing sizes, so listing WebP before AVIF means no
|
|
41
|
+
* browser ever receives the smaller file.
|
|
42
|
+
*/
|
|
43
|
+
formats?: Reactive<readonly ImageFormat[]>;
|
|
44
|
+
/**
|
|
45
|
+
* Format of the `<img>` underneath.
|
|
46
|
+
*
|
|
47
|
+
* Defaults to what the source's extension claims, falling back to JPEG.
|
|
48
|
+
* Following the source matters for PNG: a transparent logo flattened to
|
|
49
|
+
* JPEG arrives with a black background, and the fallback is exactly the
|
|
50
|
+
* path taken by the browsers least likely to be checked.
|
|
51
|
+
*/
|
|
52
|
+
fallbackFormat?: Reactive<ImageFormat | undefined>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULT_FORMATS: readonly ImageFormat[] = ["avif", "webp"];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The format for the `<img>`, never one of the modern ones.
|
|
59
|
+
*
|
|
60
|
+
* A fallback in a format the `<source>` elements already cover is not a
|
|
61
|
+
* fallback — a browser that cannot read AVIF or WebP would be handed WebP.
|
|
62
|
+
*/
|
|
63
|
+
function fallbackOf(props: PictureProps): ImageFormat {
|
|
64
|
+
const explicit = read(props.fallbackFormat);
|
|
65
|
+
if (explicit !== undefined) return explicit;
|
|
66
|
+
const claimed = formatFromSource(read(props.src));
|
|
67
|
+
return claimed === "png" ? "png" : "jpeg";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const Picture = component<PictureProps>((props) => {
|
|
71
|
+
const formats = (): readonly ImageFormat[] =>
|
|
72
|
+
readOr(props.formats, DEFAULT_FORMATS);
|
|
73
|
+
|
|
74
|
+
return html`<picture data-slot="picture">
|
|
75
|
+
${() =>
|
|
76
|
+
formats().map((format) => {
|
|
77
|
+
const srcset = resolvedSrcSet(props, format);
|
|
78
|
+
// A layout that generates no ladder (`none`, or a `fixed`
|
|
79
|
+
// image with no width) leaves nothing for a `<source>` to
|
|
80
|
+
// point at, and an empty `srcset` makes the browser pick that
|
|
81
|
+
// source and render nothing.
|
|
82
|
+
if (srcset === undefined) return null;
|
|
83
|
+
return html`<source
|
|
84
|
+
data-slot="picture-source"
|
|
85
|
+
type="${mimeType(format)}"
|
|
86
|
+
srcset="${srcset}"
|
|
87
|
+
sizes="${() => resolvedSizes(props)}"
|
|
88
|
+
/>`;
|
|
89
|
+
})}
|
|
90
|
+
${() => Image({ ...props, format: fallbackOf(props) })}
|
|
91
|
+
</picture>`;
|
|
92
|
+
});
|