Skip to content

Commit a4d5eca

Browse files
committed
feat: add dynamic image optimization
part of #241 closes #9787 This adds image optimization through a new $app/images import. It's deliberately low level: The only export is getImage which you pass an image src and it returns an object containing src and srcset (possibly more?) values which you spread on an img tag. In order to use this you need to define a path to a loader in kit.config.images. The loader takes the original img src and a width and returns a URL pointing to the optimized image. You can also modify the number of sizes and trusted domains.
1 parent a7bffde commit a4d5eca

File tree

11 files changed

+194
-1
lines changed

11 files changed

+194
-1
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/**
2+
* https://vercel.com/docs/concepts/image-optimization
3+
*/
4+
export default function loader(src: string, width: number, options?: { quality?: number }): string;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// https://vercel.com/docs/concepts/image-optimization
2+
3+
/**
4+
* @param {string} src
5+
* @param {number} width
6+
* @param {{ quality?: number }} [options]
7+
*/
8+
export default function loader(src, width, options) {
9+
const url = new URL(src, 'http://n'); // If the base is a relative URL, we need to add a dummy host to the URL
10+
if (url.pathname === '/_vercel/image') {
11+
set_param(url, 'w', width);
12+
set_param(url, 'q', options?.quality ?? 75, false);
13+
} else {
14+
url.pathname = `/_vercel/image`;
15+
set_param(url, 'url', src);
16+
set_param(url, 'w', width);
17+
set_param(url, 'q', options?.quality ?? 75);
18+
}
19+
return src === url.href ? url.href : relative_url(url);
20+
}
21+
22+
/**
23+
* @param {URL} url
24+
*/
25+
function relative_url(url) {
26+
const { pathname, search } = url;
27+
return `${pathname}${search}`;
28+
}
29+
/**
30+
* @param {URL} url
31+
* @param {string} param
32+
* @param {any} value
33+
* @param {boolean} [override]
34+
*/
35+
function set_param(url, param, value, override = true) {
36+
if (value === undefined) {
37+
return;
38+
}
39+
40+
if (value === null) {
41+
if (override || url.searchParams.has(param)) {
42+
url.searchParams.delete(param);
43+
}
44+
} else {
45+
if (override || !url.searchParams.has(param)) {
46+
url.searchParams.set(param, value);
47+
}
48+
}
49+
}

packages/adapter-vercel/index.d.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { Adapter } from '@sveltejs/kit';
22
import './ambient.js';
33

4-
export default function plugin(config?: Config): Adapter;
4+
export default function plugin(
5+
config?: Config & {
6+
/**
7+
* Enable or disable Vercel's image optimization. This is enabled by default if you have
8+
* defined the Vercel loader in your `svelte.config.js` file, else disabled by default.
9+
* https://vercel.com/docs/concepts/image-optimization
10+
*/
11+
images?: boolean;
12+
}
13+
): Adapter;
514

615
export interface ServerlessConfig {
716
/**

packages/adapter-vercel/index.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,8 +407,22 @@ function static_vercel_config(builder) {
407407
overrides[page.file] = { path: overrides_path };
408408
}
409409

410+
/** @type {Record<string, any> | undefined} */
411+
let images = undefined;
412+
const img_config = builder.config.kit.images;
413+
if (config.images || img_config.loader === '@sveltejs/adapter-vercel/image-loader') {
414+
images = {
415+
sizes: img_config.sizes,
416+
domains: img_config.domains,
417+
// TODO should we expose the following and some other optional options through the adapter?
418+
formats: ['image/avif', 'image/webp'],
419+
minimumCacheTTL: 300
420+
};
421+
}
422+
410423
return {
411424
version: 3,
425+
images,
412426
routes: [
413427
...prerendered_redirects,
414428
{

packages/adapter-vercel/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
"types": "./index.d.ts",
1616
"import": "./index.js"
1717
},
18+
"./image-loader": {
19+
"types": "./image-loader.d.ts",
20+
"import": "./image-loader.js"
21+
},
1822
"./package.json": "./package.json"
1923
},
2024
"types": "index.d.ts",

packages/kit/scripts/generate-dts.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ createBundle({
1010
'@sveltejs/kit/vite': 'src/exports/vite/index.js',
1111
'$app/environment': 'src/runtime/app/environment.js',
1212
'$app/forms': 'src/runtime/app/forms.js',
13+
'$app/images': 'src/runtime/app/images.js',
1314
'$app/navigation': 'src/runtime/app/navigation.js',
1415
'$app/paths': 'src/runtime/app/paths.js',
1516
'$app/stores': 'src/runtime/app/stores.js'

packages/kit/src/core/config/options.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ const options = object(
138138
errorTemplate: string(join('src', 'error.html'))
139139
}),
140140

141+
images: object({
142+
domains: string_array([]),
143+
loader: string(null),
144+
sizes: number_array([640, 828, 1200, 1920, 3840])
145+
}),
146+
141147
inlineStyleThreshold: number(0),
142148

143149
moduleExtensions: string_array(['.js', '.ts']),
@@ -354,6 +360,20 @@ function string(fallback, allow_empty = true) {
354360
});
355361
}
356362

363+
/**
364+
* @param {number[] | undefined} [fallback]
365+
* @returns {Validator}
366+
*/
367+
function number_array(fallback) {
368+
return validate(fallback, (input, keypath) => {
369+
if (!Array.isArray(input) || input.some((value) => typeof value !== 'number')) {
370+
throw new Error(`${keypath} must be an array of numbers, if specified`);
371+
}
372+
373+
return input;
374+
});
375+
}
376+
357377
/**
358378
* @param {string[] | undefined} [fallback]
359379
* @returns {Validator}

packages/kit/src/exports/public.d.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,31 @@ export interface KitConfig {
430430
*/
431431
errorTemplate?: string;
432432
};
433+
/**
434+
* Image optimization configuration
435+
*/
436+
images?: {
437+
/**
438+
* Path to a a file that contains a loader that will be used to generate the an image URL out of the given source and width.
439+
* It optionally also takes third parameter for options.
440+
*
441+
* ```js
442+
* export default function loader(src, width, opts) {
443+
* return `https://example.com/${src}?w=${width}&q=${opts.quality || 75}`;
444+
* }
445+
* ```
446+
*/
447+
loader?: string;
448+
/**
449+
* Which srcset sizes to generate
450+
* @default [640, 828, 1200, 1920, 3840]
451+
*/
452+
sizes?: number[];
453+
/**
454+
* Which external domains to trust when optimizing images
455+
*/
456+
domains?: string[];
457+
};
433458
/**
434459
* Inline CSS inside a `<style>` block at the head of the HTML. This option is a number that specifies the maximum length of a CSS file to be inlined. All CSS files needed for the page and smaller than this value are merged and inlined in a `<style>` block.
435460
*

packages/kit/src/exports/vite/index.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,19 @@ function kit({ svelte_config }) {
441441
}
442442
`;
443443
}
444+
445+
case '\0__sveltekit/images': {
446+
const { images } = svelte_config.kit;
447+
const loader = images.loader
448+
? `export { default as loader } from '${images.loader}';`
449+
: 'export function loader(src) { console.warn("No image loader in kit.config.kit.images.loader set, images will not be optimized."); return src; }';
450+
451+
return dedent`
452+
export const sizes = ${JSON.stringify(images.sizes)};
453+
export const domains = ${JSON.stringify(images.domains)};
454+
${loader}
455+
`;
456+
}
444457
}
445458
}
446459
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { DEV } from 'esm-env';
2+
import { sizes, loader, domains } from '__sveltekit/images';
3+
4+
/**
5+
* @param {string} src
6+
* @param {any} [options]
7+
* @returns {{ src: string, srcset?: string }}
8+
*/
9+
export function getImage(src, options) {
10+
if (DEV) {
11+
if (!matches_domain(src)) {
12+
console.warn(
13+
`$app/images: Image src '${src}' does not match any of the allowed domains and will therefore not be optimized.`
14+
);
15+
}
16+
return { srcset: src, src };
17+
}
18+
19+
if (!matches_domain(src)) {
20+
return { src };
21+
}
22+
23+
const srcset = sizes
24+
.map((size) => {
25+
const url = loader(src, size, options);
26+
const w = size + 'w';
27+
return `${url} ${w}`;
28+
})
29+
.join(', ');
30+
const _src = loader(src, sizes[sizes.length - 1], options);
31+
32+
// Order of attributes is important here as they are set in this order
33+
// and having src before srcset would result in a flicker
34+
return { srcset, src: _src };
35+
}
36+
37+
/**
38+
* @param {string} src
39+
*/
40+
function matches_domain(src) {
41+
const url = new URL(src, 'http://n'); // if src is protocol relative, use dummy domain
42+
if (url.href === src) {
43+
return domains.some((domain) => url.hostname === domain);
44+
} else {
45+
return true; // relative urls are always ok
46+
}
47+
}

packages/kit/src/types/ambient.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,10 @@ declare module '__sveltekit/paths' {
106106
export function override(paths: { base: string; assets: string }): void;
107107
export function set_assets(path: string): void;
108108
}
109+
110+
/** Internal version of $app/images */
111+
declare module '__sveltekit/images' {
112+
export let sizes: number[];
113+
export let loader: (url: string, size: number, opts?: any) => string;
114+
export let domains: string[];
115+
}

0 commit comments

Comments
 (0)