Skip to content

Commit

Permalink
feat: add dynamic image optimization
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
dummdidumm committed Jul 5, 2023
1 parent a7bffde commit a4d5eca
Show file tree
Hide file tree
Showing 11 changed files with 194 additions and 1 deletion.
4 changes: 4 additions & 0 deletions packages/adapter-vercel/image-loader.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* https://vercel.com/docs/concepts/image-optimization
*/
export default function loader(src: string, width: number, options?: { quality?: number }): string;
49 changes: 49 additions & 0 deletions packages/adapter-vercel/image-loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// https://vercel.com/docs/concepts/image-optimization

/**
* @param {string} src
* @param {number} width
* @param {{ quality?: number }} [options]
*/
export default function loader(src, width, options) {
const url = new URL(src, 'http://n'); // If the base is a relative URL, we need to add a dummy host to the URL
if (url.pathname === '/_vercel/image') {
set_param(url, 'w', width);
set_param(url, 'q', options?.quality ?? 75, false);
} else {
url.pathname = `/_vercel/image`;
set_param(url, 'url', src);
set_param(url, 'w', width);
set_param(url, 'q', options?.quality ?? 75);
}
return src === url.href ? url.href : relative_url(url);
}

/**
* @param {URL} url
*/
function relative_url(url) {
const { pathname, search } = url;
return `${pathname}${search}`;
}
/**
* @param {URL} url
* @param {string} param
* @param {any} value
* @param {boolean} [override]
*/
function set_param(url, param, value, override = true) {
if (value === undefined) {
return;
}

if (value === null) {
if (override || url.searchParams.has(param)) {
url.searchParams.delete(param);
}
} else {
if (override || !url.searchParams.has(param)) {
url.searchParams.set(param, value);
}
}
}
11 changes: 10 additions & 1 deletion packages/adapter-vercel/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { Adapter } from '@sveltejs/kit';
import './ambient.js';

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

export interface ServerlessConfig {
/**
Expand Down
14 changes: 14 additions & 0 deletions packages/adapter-vercel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -407,8 +407,22 @@ function static_vercel_config(builder) {
overrides[page.file] = { path: overrides_path };
}

/** @type {Record<string, any> | undefined} */
let images = undefined;
const img_config = builder.config.kit.images;
if (config.images || img_config.loader === '@sveltejs/adapter-vercel/image-loader') {
images = {
sizes: img_config.sizes,
domains: img_config.domains,
// TODO should we expose the following and some other optional options through the adapter?
formats: ['image/avif', 'image/webp'],
minimumCacheTTL: 300
};
}

return {
version: 3,
images,
routes: [
...prerendered_redirects,
{
Expand Down
4 changes: 4 additions & 0 deletions packages/adapter-vercel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"types": "./index.d.ts",
"import": "./index.js"
},
"./image-loader": {
"types": "./image-loader.d.ts",
"import": "./image-loader.js"
},
"./package.json": "./package.json"
},
"types": "index.d.ts",
Expand Down
1 change: 1 addition & 0 deletions packages/kit/scripts/generate-dts.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ createBundle({
'@sveltejs/kit/vite': 'src/exports/vite/index.js',
'$app/environment': 'src/runtime/app/environment.js',
'$app/forms': 'src/runtime/app/forms.js',
'$app/images': 'src/runtime/app/images.js',
'$app/navigation': 'src/runtime/app/navigation.js',
'$app/paths': 'src/runtime/app/paths.js',
'$app/stores': 'src/runtime/app/stores.js'
Expand Down
20 changes: 20 additions & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ const options = object(
errorTemplate: string(join('src', 'error.html'))
}),

images: object({
domains: string_array([]),
loader: string(null),
sizes: number_array([640, 828, 1200, 1920, 3840])
}),

inlineStyleThreshold: number(0),

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

/**
* @param {number[] | undefined} [fallback]
* @returns {Validator}
*/
function number_array(fallback) {
return validate(fallback, (input, keypath) => {
if (!Array.isArray(input) || input.some((value) => typeof value !== 'number')) {
throw new Error(`${keypath} must be an array of numbers, if specified`);
}

return input;
});
}

/**
* @param {string[] | undefined} [fallback]
* @returns {Validator}
Expand Down
25 changes: 25 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,31 @@ export interface KitConfig {
*/
errorTemplate?: string;
};
/**
* Image optimization configuration
*/
images?: {
/**
* 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.
* It optionally also takes third parameter for options.
*
* ```js
* export default function loader(src, width, opts) {
* return `https://example.com/${src}?w=${width}&q=${opts.quality || 75}`;
* }
* ```
*/
loader?: string;
/**
* Which srcset sizes to generate
* @default [640, 828, 1200, 1920, 3840]
*/
sizes?: number[];
/**
* Which external domains to trust when optimizing images
*/
domains?: string[];
};
/**
* 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.
*
Expand Down
13 changes: 13 additions & 0 deletions packages/kit/src/exports/vite/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,19 @@ function kit({ svelte_config }) {
}
`;
}

case '\0__sveltekit/images': {
const { images } = svelte_config.kit;
const loader = images.loader
? `export { default as loader } from '${images.loader}';`
: 'export function loader(src) { console.warn("No image loader in kit.config.kit.images.loader set, images will not be optimized."); return src; }';

return dedent`
export const sizes = ${JSON.stringify(images.sizes)};
export const domains = ${JSON.stringify(images.domains)};
${loader}
`;
}
}
}
};
Expand Down
47 changes: 47 additions & 0 deletions packages/kit/src/runtime/app/images.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { DEV } from 'esm-env';
import { sizes, loader, domains } from '__sveltekit/images';

/**
* @param {string} src
* @param {any} [options]
* @returns {{ src: string, srcset?: string }}
*/
export function getImage(src, options) {
if (DEV) {
if (!matches_domain(src)) {
console.warn(
`$app/images: Image src '${src}' does not match any of the allowed domains and will therefore not be optimized.`
);
}
return { srcset: src, src };
}

if (!matches_domain(src)) {
return { src };
}

const srcset = sizes
.map((size) => {
const url = loader(src, size, options);
const w = size + 'w';
return `${url} ${w}`;
})
.join(', ');
const _src = loader(src, sizes[sizes.length - 1], options);

// Order of attributes is important here as they are set in this order
// and having src before srcset would result in a flicker
return { srcset, src: _src };
}

/**
* @param {string} src
*/
function matches_domain(src) {
const url = new URL(src, 'http://n'); // if src is protocol relative, use dummy domain
if (url.href === src) {
return domains.some((domain) => url.hostname === domain);
} else {
return true; // relative urls are always ok
}
}
7 changes: 7 additions & 0 deletions packages/kit/src/types/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,10 @@ declare module '__sveltekit/paths' {
export function override(paths: { base: string; assets: string }): void;
export function set_assets(path: string): void;
}

/** Internal version of $app/images */
declare module '__sveltekit/images' {
export let sizes: number[];
export let loader: (url: string, size: number, opts?: any) => string;
export let domains: string[];
}

0 comments on commit a4d5eca

Please sign in to comment.