Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add dynamic image optimization #10323

Draft
wants to merge 26 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 68 additions & 4 deletions documentation/docs/30-advanced/60-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,77 @@ By default, enhanced images will be transformed to more efficient formats. Howev

[See the imagetools repo for the full list of directives](https://github.com/JonasKruckenberg/imagetools/blob/main/docs/directives.md).

## Loading images dynamically from a CDN
## `$app/images`

In some cases, the images may not be accessible at build time — e.g. they may live inside a content management system or elsewhere.
In some cases, the images may not be accessible at build time — e.g. they may live inside a content management system or elsewhere. SvelteKit provides a helper through `$app/images` to make it possible to load these images using best practices. In its simplest form, you pass an image url and the intrinsic width and height of the image to `getImage` and spread the result onto an `img` tag:

Using a content delivery network (CDN) can allow you to optimize these images dynamically, and provides more flexibility with regards to sizes, but it may involve some setup overhead and usage costs. Depending on caching strategy, the browser may not be able to use a cached copy of the asset until a [304 response](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) is received from the CDN. Building HTML to target CDNs may result in slightly smaller and simpler HTML because they can serve the appropriate file format for an `<img>` tag based on the `User-Agent` header whereas build-time optimizations must produce `<picture>` tags with multiple sources. Finally, some CDNs may generate images lazily, which could have a negative performance impact for sites with low traffic and frequently changing images.
```svelte
<script>
import { getImage } from '$app/images';
</script>

<img {...getImage({ src: '/path/to/image', width: 1000, height: 800 })} alt="An alt text" />
```

The result in this case will be generated `src` and `srcset` attributes. The `srcset` will contain two resolution variants and the browser decides based on the device's pixel density which image to load.

If you provide the `sizes` property or set `layout: "fill"` instead of `width` and `height`, the `srcset` will instead contain a list of URLs for various device widths and the browser will choose the appropriate one.

```svelte
<script>
import { getImage } from '$app/images';
</script>

<img {...getImage({ src: '/path/to/image', width: 1000, height: 800, sizes: '100vw' })} alt="An alt text" />
```

Remember to give the largest image above the fold (i.e. the largest one that is immediately visible) a `fetchpriority` of `high` to ensure browsers download it earlier and set `loading: 'lazy'` for unimportant images, which both contributes to better web vital scores.

```svelte
<script>
import { getImage } from '$app/images';
</script>

<img {...getImage({ src: '/path/to/image', width: 1000, height: 800, sizes: '100vw', fetchpriority: 'high' })} alt="An alt text" />
```

Because `getImage` is just a simple function, you can wrap it in a function to set sensible defaults for your app, or use it as a base for a custom image component.

### Configurating an image loader

`getImage` calculates the variants for an image based on its input and the `kit.images.width` configuration, which contains an array of numbers corresponding to various screen widths. For each variant, the configured image loader is invoked when running in production - at dev time, the loader is not invoked to prevent accidental image CDN costs.

By default, no loader is configured and as such the image url is handed back without any modifications. To configure an image loader, pass a string to `kit.images.loader` which represents a path to a file on disk (either a relative path, or referencing an npm package). Some adapters like [`adapter-vercel`](adapter-vercel) provide ready-made loaders. It's also very easy to create your own loader. Here's an example using [`unpic`](https://unpic.pics/lib/), which provides solutions for a wide range of image CDNs:

```js
/// file: custom-loader.js
// @filename: ambient.d.ts
declare module 'unpic/transformers/shopify' {
export function transform(args: any): string;
}

// @filename: index.js
// ---cut---
// This example uses shopify as the target image CDN
import { transform } from 'unpic/transformers/shopify';

/**
* @param {string} url
* @param {number} width
* @param {Record<string, any>} options
*/
export default function loader(url, width, options) {
return transform({ url, width, ...options });
}
```

A loader optionally takes a third parameter which corresponds to the `options` property passed to `getImage`. This allows for more control over the image transformation process, if needed.

### Other considerations

Using a content delivery network (CDN) can allow you to optimize images dynamically, and provides more flexibility with regards to sizes, but it may involve some setup overhead and usage costs. Depending on caching strategy, the browser may not be able to use a cached copy of the asset until a [304 response](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) is received from the CDN. Building HTML to target CDNs may result in slightly smaller and simpler HTML because they can serve the appropriate file format for an `<img>` tag based on the `User-Agent` header whereas build-time optimizations must produce `<picture>` tags with multiple sources. Finally, some CDNs may generate images lazily, which could have a negative performance impact for sites with low traffic and frequently changing images.

CDNs can generally be used without any need for a library. However, there are a number of libraries with Svelte support that make it easier. [`@unpic/svelte`](https://unpic.pics/img/svelte/) is a CDN-agnostic library with support for a large number of providers. You may also find that specific CDNs like [Cloudinary](https://svelte.cloudinary.dev/) have Svelte support. Finally, some content management systems (CMS) which support Svelte (such as [Contentful](https://www.contentful.com/sveltekit-starter-guide/), [Storyblok](https://github.com/storyblok/storyblok-svelte), and [Contentstack](https://www.contentstack.com/docs/developers/sample-apps/build-a-starter-website-with-sveltekit-and-contentstack)) have built-in support for image handling.
CDNs can generally be used without any need for a library, but calling the CDN and ensuring that smaller images are called for smaller screens is generally up to you. Besides using `$app/images`, there are a number of libraries with Svelte support that make it easier. [`@unpic/svelte`](https://unpic.pics/img/svelte/) is a CDN-agnostic library with support for a large number of providers. You may also find that specific CDNs like [Cloudinary](https://svelte.cloudinary.dev/) have Svelte support. Finally, some content management systems (CMS) which support Svelte (such as [Contentful](https://www.contentful.com/sveltekit-starter-guide/), [Storyblok](https://github.com/storyblok/storyblok-svelte), and [Contentstack](https://www.contentstack.com/docs/developers/sample-apps/build-a-starter-website-with-sveltekit-and-contentstack)) have built-in support for image handling.

## Best practices

Expand Down
8 changes: 8 additions & 0 deletions packages/adapter-vercel/image-loader.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* https://vercel.com/docs/concepts/image-optimization
*/
export default function loader(
src: string,
width: number,
image_options?: { quality?: number }
): string;
55 changes: 55 additions & 0 deletions packages/adapter-vercel/image-loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// https://vercel.com/docs/concepts/image-optimization

/**
* @param {string} src
Comment on lines +1 to +4
Copy link
Member

@benmccann benmccann Oct 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// https://vercel.com/docs/concepts/image-optimization
/**
* @param {string} src
/**
* https://vercel.com/docs/concepts/image-optimization
* @param {string} src

* @param {number} width
* @param {{ quality?: number }} [image_options]
*/
export default function loader(src, width, image_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);
if (image_options?.quality) {
set_param(url, 'q', image_options.quality, false);
}
} else {
url.pathname = '_vercel/image';
set_param(url, 'url', src);
set_param(url, 'w', width);
if (image_options?.quality) {
set_param(url, 'q', image_options.quality);
}
}

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);
}
}
}
37 changes: 36 additions & 1 deletion packages/adapter-vercel/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,42 @@
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 `kit.images.loader`, else disabled by default.
Copy link
Member

@benmccann benmccann Oct 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be nice to show how to do this. E.g. do you set kit.images.loader: '@sveltejs/adapter-vercel/image-loader'? There's probably some sort of documentation we should add in documentation/docs/25-build-and-deploy/90-adapter-vercel.md

* https://vercel.com/docs/concepts/image-optimization
*/
images?: ImageConfig;
}
): Adapter;

/**
* Define how Vercel should optimize your images. Links to the documentation:
* - https://vercel.com/docs/concepts/image-optimization
* - https://vercel.com/docs/build-output-api/v3/configuration#images
*/
export interface ImageConfig {
/** Only set this if you're not using SvelteKit's `getImage` from `$app/images` */
sizes?: number[];
domains?: string[];
remotePatterns?: RemotePattern[];
minimumCacheTTL?: number;
formats?: ImageFormat[];
dangerouslyAllowSVG?: boolean;
contentSecurityPolicy?: string;
contentDispositionType?: string;
}

type ImageFormat = 'image/avif' | 'image/webp';

type RemotePattern = {
protocol?: 'http' | 'https';
hostname: string;
port?: string;
pathname?: string;
};

export interface ServerlessConfig {
/**
Expand Down
24 changes: 20 additions & 4 deletions packages/adapter-vercel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const plugin = function (defaults = {}) {
functions: `${dir}/functions`
};

const static_config = static_vercel_config(builder);
const static_config = static_vercel_config(builder, defaults.images);

builder.log.minor('Generating serverless function...');

Expand Down Expand Up @@ -366,9 +366,12 @@ function write(file, data) {
fs.writeFileSync(file, data);
}

// This function is duplicated in adapter-static
/** @param {import('@sveltejs/kit').Builder} builder */
function static_vercel_config(builder) {
// This function is mostly duplicated in adapter-static
/**
* @param {import('@sveltejs/kit').Builder} builder
* @param {import('./index.js').ImageConfig | undefined}
*/
function static_vercel_config(builder, image_config) {
/** @type {any[]} */
const prerendered_redirects = [];

Expand Down Expand Up @@ -406,8 +409,21 @@ 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;
const is_vercel_loader = img_config.loader === '@sveltejs/adapter-vercel/image-loader';
if (image_config || is_vercel_loader) {
images = {
...image_config,
sizes: is_vercel_loader ? img_config.widths : image_config?.sizes ?? img_config.widths
// TODO should we set some defaults?
};
}

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
6 changes: 6 additions & 0 deletions packages/kit/src/core/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import * as url from 'node:url';
import options from './options.js';
import { posixify } from '../../utils/filesystem.js';

/**
* Loads the template (src/app.html by default) and validates that it has the
Expand Down Expand Up @@ -91,6 +92,11 @@ function process_config(config, { cwd = process.cwd() } = {}) {
}
}

// Resolve relative loader urls
if (validated.kit.images.loader[0] === '.') {
validated.kit.images.loader = posixify(path.resolve(cwd, validated.kit.images.loader));
}

return validated;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ const get_defaults = (prefix = '') => ({
appTemplate: join(prefix, 'src/app.html'),
errorTemplate: join(prefix, 'src/error.html')
},
images: {
loader: '',
widths: [48, 128, 256, 540, 768, 1080, 1366, 1536, 1920, 2560, 3000, 4096, 5120]
},
inlineStyleThreshold: 0,
moduleExtensions: ['.js', '.ts'],
output: { preloadStrategy: 'modulepreload' },
Expand Down
21 changes: 21 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,13 @@ const options = object(
errorTemplate: string(join('src', 'error.html'))
}),

images: object({
loader: string(''),
widths: number_array([
48, 128, 256, 540, 768, 1080, 1366, 1536, 1920, 2560, 3000, 4096, 5120
])
}),

inlineStyleThreshold: number(0),

moduleExtensions: string_array(['.js', '.ts']),
Expand Down Expand Up @@ -354,6 +361,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
21 changes: 21 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,27 @@ export interface KitConfig {
*/
errorTemplate?: string;
};
/**
* Image optimization configuration
*/
images?: {
/**
* Path to a module 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. Can be either a relative path or a reference to an npm packe.
*
* ```ts
* export default function loader(src: string, width: number, opts: any) {
* return `https://example.com/${src}?w=${width}&q=${opts.quality || 75}`;
* }
* ```
*/
loader: string;
/**
* Which srcset sizes to generate
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this description correct? I thought it was the screen sizes. I know you had a device_sizes variable in #10323 and these look like pretty large numbers. I think only some of these will get generated based on the device being used, which would be helpful to include in the documentation

Copy link
Member Author

@dummdidumm dummdidumm Oct 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have to tell Vercel (and some other image optimization providers, too) which image widths / sizes / call-it-whatever-you-want are allowed to be generated. 3840 may sound like a big number, but it really isn't when you think about a 4k resolution laptop screen.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main problem with this config and the domain config is that image optimization providers may have specific settings / requirements. The question now is - how do you make it so that you don't have to duplicate the config? Either you put a common denominator inside the Kit config, and adapters can read from that config, or you provide that config through the adapters, but then we need a way to get the config from the adapter into Kit.

Copy link
Member

@benmccann benmccann Oct 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd not have any common config. E.g. the domains config seems rather Vercel-specific. As an example, I just checked Cloudinary and they seem not to have such an option.

sizes is very Vercel-specific as well. E.g. I just checked Bunny, Cloudinary, and Contentful and it doesn't seem any have such a concept

I don't think it's bad if you end up having the same config in different implementations. And in fact it might be helpful because different implementations might have config with the same name that behaves slightly differently

* @default [48, 128, 256, 540, 768, 1080, 1366, 1536, 1920, 2560, 3000, 4096, 5120]
*/
widths?: number[];
};
/**
* 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 in UTF-16 code units, as specified by the [String.length](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length) property, 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
12 changes: 12 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,18 @@ function kit({ svelte_config }) {
}
`;
}

case '\0virtual:__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 widths = ${JSON.stringify(images.widths)};
${loader}
`;
}
}
}
};
Expand Down
Loading
Loading