Skip to content

Commit c52aab6

Browse files
authored
feat: image preprocessor (#10788)
1 parent 43c5f2c commit c52aab6

File tree

25 files changed

+968
-212
lines changed

25 files changed

+968
-212
lines changed

.changeset/eighty-timers-exist.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/static-img': patch
3+
---
4+
5+
feat: add experimental `@sveltejs/enhanced-img` package

.changeset/rare-owls-tease.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
fix: add vite.config.js to included files in generated tsconfig

documentation/docs/30-advanced/60-assets.md

Lines changed: 0 additions & 23 deletions
This file was deleted.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
---
2+
title: Images
3+
---
4+
5+
Images can have a big impact on your app's performance. For best results, you should optimize them by doing the following:
6+
7+
- generate optimal formats like `.avif` and `.webp`
8+
- create different sizes for different screens
9+
- ensure that assets can be cached effectively
10+
11+
Doing this manually is tedious. There are a variety of techniques you can use, depending on your needs and preferences.
12+
13+
## Vite's built-in handling
14+
15+
[Vite will automatically process imported assets](https://vitejs.dev/guide/assets.html) for improved performance. This includes assets referenced via the CSS `url()` function. Hashes will be added to the filenames so that they can be cached, and assets smaller than `assetsInlineLimit` will be inlined. Vite's asset handling is most often used for images, but is also useful for video, audio, etc.
16+
17+
```svelte
18+
<script>
19+
import logo from '$lib/assets/logo.png';
20+
</script>
21+
22+
<img alt="The project logo" src={logo} />
23+
```
24+
25+
## @sveltejs/enhanced-img
26+
27+
> **WARNING**: The `@sveltejs/enhanced-img` package is experimental. It uses pre-1.0 versioning and may introduce breaking changes with every minor version release.
28+
29+
`@sveltejs/enhanced-img` builds on top of Vite's built-in asset handling. It offers plug and play image processing that serves smaller file formats like `avif` or `webp`, automatically sets the intrinsic `width` and `height` of the image to avoid layout shift, creates images of multiple sizes for various devices, and strips EXIF data for privacy. It will work in any Vite-based project including, but not limited to, SvelteKit projects.
30+
31+
### Setup
32+
33+
Install:
34+
35+
```bash
36+
npm install --save-dev @sveltejs/enhanced-img
37+
```
38+
39+
Adjust `vite.config.js`:
40+
41+
```diff
42+
import { sveltekit } from '@sveltejs/kit/vite';
43+
+import { enhancedImages } from '@sveltejs/enhanced-img';
44+
import { defineConfig } from 'vite';
45+
46+
export default defineConfig({
47+
plugins: [
48+
+ enhancedImages(),
49+
sveltekit()
50+
]
51+
});
52+
```
53+
54+
### Basic usage
55+
56+
Use in your `.svelte` components by using `<enhanced:img>` rather than `<img>` and referencing the image file with a [Vite asset import](https://vitejs.dev/guide/assets.html#static-asset-handling) path:
57+
58+
```svelte
59+
<enhanced:img src="./path/to/your/image.jpg" alt="An alt text" />
60+
```
61+
62+
At build time, your `<enhanced:img>` tag will be replaced with an `<img>` wrapped by a `<picture>` providing multiple image types and sizes. It's only possible to downscale images without losing quality, which means that you should provide the highest resolution image that you need — smaller versions will be generated for the various device types that may request an image.
63+
64+
You should provide your image at 2x resolution for HiDPI displays (a.k.a. retina displays). `<enhanced:img>` will automatically take care of serving smaller versions to smaller devices.
65+
66+
If you wish to add styles to your `<enhanced:img>`, you should add a `class` and target that.
67+
68+
### Dynamically choosing an image
69+
70+
You can also manually import an image asset and pass it to an `<enhanced:img>`. This is useful when you have a collection of static images and would like to dynamically choose one or [iterate over them](https://github.com/sveltejs/kit/blob/master/sites/kit.svelte.dev/src/routes/home/Showcase.svelte). In this case you will need to update both the `import` statement and `<img>` element as shown below to indicate you'd like process them.
71+
72+
```svelte
73+
<script>
74+
import { MyImage } from './path/to/your/image.jpg?enhanced';
75+
</script>
76+
77+
<enhanced:img src={MyImage} alt="Some alt text" />
78+
```
79+
80+
You can also use [Vite's `import.meta.glob`](https://vitejs.dev/guide/features.html#glob-import). Note that you will have to specify `enhanced` via a [custom query](https://vitejs.dev/guide/features.html#custom-queries):
81+
82+
```js
83+
const pictures = import.meta.glob(
84+
'/path/to/assets/*.{avif,gif,heif,jpeg,jpg,png,tiff,webp}',
85+
{
86+
query: {
87+
enhanced: true
88+
}
89+
}
90+
);
91+
```
92+
93+
### Intrinsic Dimensions
94+
95+
`width` and `height` are optional as they can be inferred from the source image and will be automatically added when the `<enhanced:img>` tag is preprocessed. With these attributes, the browser can reserve the correct amount of space, preventing [layout shift](https://web.dev/articles/cls). If you'd like to use a different `width` and `height` you can style the image with CSS. Because the preprocessor adds a `width` and `height` for you, if you'd like one of the dimensions to be automatically calculated then you will need to specify that:
96+
97+
```svelte
98+
<style>
99+
.hero-image img {
100+
width: var(--size);
101+
height: auto;
102+
}
103+
</style>
104+
```
105+
106+
### `srcset` and `sizes`
107+
108+
If you have a large image, such as a hero image taking the width of the design, you should specify `sizes` so that smaller versions are requested on smaller devices. E.g. if you have a 1280px image you may want to specify something like:
109+
110+
```svelte
111+
<enhanced:img src="./image.png" sizes="min(1280px, 100vw)"/>
112+
```
113+
114+
If `sizes` is specified, `<enhanced:img>` will generate small images for smaller devices and populate the `srcset` attribute.
115+
116+
The smallest picture generated automatically will have a width of 540px. If you'd like smaller images or would otherwise like to specify custom widths, you can do that with the `w` query parameter:
117+
```svelte
118+
<enhanced:img
119+
src="./image.png?w=1280;640;400"
120+
sizes="(min-width:1920px) 1280px, (min-width:1080px) 640px, (min-width:768px) 400px"
121+
/>
122+
```
123+
124+
If `sizes` is not provided, then a HiDPI/Retina image and a standard resolution image will be generated. The image you provide should be 2x the resolution you wish to display so that the browser can display that image on devices with a high [device pixel ratio](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio).
125+
126+
### Per-image transforms
127+
128+
By default, enhanced images will be transformed to more efficient formats. However, you may wish to apply other transforms such as a blur, quality, flatten, or rotate operation. You can run per-image transforms by appending a query string:
129+
130+
```svelte
131+
<enhanced:img src="./path/to/your/image.jpg?blur=15" alt="An alt text" />
132+
```
133+
134+
[See the imagetools repo for the full list of directives](https://github.com/JonasKruckenberg/imagetools/blob/main/docs/directives.md).
135+
136+
## Loading images dynamically from a CDN
137+
138+
In some cases, the images may not be accessible at build time — e.g. they may live inside a content management system or elsewhere.
139+
140+
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. We do not currently offer any tools for dynamic image transforms, but we may offer such utilities in the future.
141+
142+
## Best practices
143+
144+
- For each image type, use the appropriate solution from those discussed above. You can mix and match all three solutions in one project. For example, you may use Vite's built-in handling to provide images for `<meta>` tags, display images on your homepage with `@sveltejs/enhanced-img`, and display user-submitted content with a dynamic approach.
145+
- Consider serving all images via CDN regardless of the image optimization types you use. CDNs reduce latency by distributing copies of static assets globally.
146+
- Your original images should have a good quality/resolution and should have 2x the width it will be displayed at to serve HiDPI devices. Image processing can size images down to save bandwidth when serving smaller screens, but it would be a waste of bandwidth to invent pixels to size images up.
147+
- For images which are much larger than the width of a mobile device (roughly 400px), such as a hero image taking the width of the page design, specify `sizes` so that smaller images can be served on smaller devices.
148+
- Choose one image per page which is the most important/largest one and give it `priority` so it loads faster. This gives you better web vitals scores (largest contentful paint in particular).
149+
- Give the image a container or styling so that it is constrained and does not jump around. `width` and `height` help the browser reserving space while the image is still loading. `@sveltejs/enhanced-img` will add a `width` and `height` for you.
150+
- Always provide a good `alt` text. The Svelte compiler will warn you if you don't do this.

packages/enhanced-img/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# `@sveltejs/enhanced-img`
2+
3+
A Vite plugin which runs a Svelte preprocessor to locate images and then transform them at build-time.
4+
5+
**WARNING**: This package is experimental. It uses pre-1.0 versioning and may introduce breaking changes with every minor version release.
6+
7+
## Docs
8+
9+
[Docs](https://kit.svelte.dev/docs/images)
10+
11+
## Changelog
12+
13+
[The Changelog for this package is available on GitHub](https://github.com/sveltejs/kit/blob/master/packages/enhanced-img/CHANGELOG.md).
14+
15+
## Acknowledgements
16+
17+
We'd like to thank the author of `svelte-preprocess-import-assets`, which this code is partially based off of. We'd also like to thank the authors of `vite-imagetools` which is used in `@sveltejs/enhanced-img`.

packages/enhanced-img/package.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "@sveltejs/enhanced-img",
3+
"version": "0.1.0",
4+
"description": "Image optimization for your Svelte apps",
5+
"repository": {
6+
"type": "git",
7+
"url": "https://github.com/sveltejs/kit",
8+
"directory": "packages/image"
9+
},
10+
"license": "MIT",
11+
"homepage": "https://kit.svelte.dev",
12+
"type": "module",
13+
"scripts": {
14+
"lint": "prettier --check . --config ../../.prettierrc --ignore-path .gitignore",
15+
"check": "tsc",
16+
"format": "prettier --write . --config ../../.prettierrc --ignore-path .gitignore",
17+
"test": "vitest"
18+
},
19+
"files": [
20+
"src",
21+
"types"
22+
],
23+
"exports": {
24+
"types": "./types/index.d.ts",
25+
"import": "./src/index.js"
26+
},
27+
"types": "types/index.d.ts",
28+
"dependencies": {
29+
"magic-string": "^0.30.0",
30+
"svelte-parse-markup": "^0.1.1",
31+
"vite-imagetools": "^6.2.3"
32+
},
33+
"devDependencies": {
34+
"@types/estree": "^1.0.2",
35+
"@types/node": "^16.18.6",
36+
"estree-walker": "^3.0.3",
37+
"svelte": "^4.0.5",
38+
"typescript": "^4.9.4",
39+
"vite": "^4.4.2",
40+
"vitest": "^0.34.0"
41+
}
42+
}

packages/enhanced-img/src/index.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import path from 'node:path';
2+
import { image } from './preprocessor.js';
3+
4+
/**
5+
* @returns {Promise<import('vite').Plugin[]>}
6+
*/
7+
export async function enhancedImages() {
8+
const imagetools_plugin = await imagetools();
9+
if (!imagetools_plugin) {
10+
console.error(
11+
'@sveltejs/enhanced-img: vite-imagetools is not installed. Skipping build-time optimizations'
12+
);
13+
}
14+
return imagetools_plugin && !process.versions.webcontainer
15+
? [image_plugin(imagetools_plugin), imagetools_plugin]
16+
: [];
17+
}
18+
19+
/**
20+
* Creates the Svelte image plugin which provides the preprocessor.
21+
* @param {import('vite').Plugin} imagetools_plugin
22+
* @returns {import('vite').Plugin}
23+
*/
24+
function image_plugin(imagetools_plugin) {
25+
/**
26+
* @type {{
27+
* plugin_context: import('rollup').PluginContext
28+
* imagetools_plugin: import('vite').Plugin
29+
* }}
30+
*/
31+
const opts = {
32+
// @ts-expect-error populated when build starts so we cheat on type
33+
plugin_context: undefined,
34+
imagetools_plugin
35+
};
36+
const preprocessor = image(opts);
37+
38+
return {
39+
name: 'vite-plugin-enhanced-img',
40+
api: {
41+
sveltePreprocess: preprocessor
42+
},
43+
buildStart() {
44+
opts.plugin_context = this;
45+
}
46+
};
47+
}
48+
49+
/** @type {Record<string,string>} */
50+
const fallback = {
51+
'.avif': 'png',
52+
'.gif': 'gif',
53+
'.heif': 'jpg',
54+
'.jpeg': 'jpg',
55+
'.jpg': 'jpg',
56+
'.png': 'png',
57+
'.tiff': 'jpg',
58+
'.webp': 'png'
59+
};
60+
61+
async function imagetools() {
62+
/** @type {typeof import('vite-imagetools').imagetools} */
63+
let imagetools;
64+
try {
65+
({ imagetools } = await import('vite-imagetools'));
66+
} catch (err) {
67+
return;
68+
}
69+
70+
/** @type {Partial<import('vite-imagetools').VitePluginOptions>} */
71+
const imagetools_opts = {
72+
defaultDirectives: async ({ pathname, searchParams: qs }, metadata) => {
73+
if (!qs.has('enhanced')) return new URLSearchParams();
74+
75+
const img_width = qs.get('imgWidth');
76+
const width = img_width ? parseInt(img_width) : (await metadata()).width;
77+
if (!width) {
78+
console.warn(`Could not determine width of image ${pathname}`);
79+
return new URLSearchParams();
80+
}
81+
82+
const { widths, kind } = get_widths(width, qs.get('imgSizes'));
83+
return new URLSearchParams({
84+
as: 'picture',
85+
format: `avif;webp;${fallback[path.extname(pathname)] ?? 'png'}`,
86+
w: widths.join(';'),
87+
...(kind === 'x' && !qs.has('w') && { basePixels: widths[0].toString() })
88+
});
89+
},
90+
namedExports: false
91+
};
92+
93+
// TODO: should we make formats or sizes configurable besides just letting people override defaultDirectives?
94+
// TODO: generate img rather than picture if only a single format is provided
95+
// by resolving the directives for the URL in the preprocessor
96+
return imagetools(imagetools_opts);
97+
}
98+
99+
/**
100+
* @param {number} width
101+
* @param {string | null} sizes
102+
* @returns {{ widths: number[]; kind: 'w' | 'x' }}
103+
*/
104+
function get_widths(width, sizes) {
105+
// We don't really know what the user wants here. But if they have an image that's really big
106+
// then we can probably assume they're always displaying it full viewport/breakpoint.
107+
// If the user is displaying a responsive image then the size usually doesn't change that much
108+
// Instead, the number of columns in the design may reduce and the image may take a greater
109+
// fraction of the screen.
110+
// Assume if they're bothering to specify sizes that it's going to take most of the screen
111+
// as that's the case where an image may be rendered at very different sizes. Otherwise, it's
112+
// probably a responsive image and a single size is okay (two when accounting for HiDPI).
113+
if (sizes) {
114+
// Use common device sizes. Doesn't hurt to include larger sizes as the user will rarely
115+
// provide an image that large.
116+
// https://screensiz.es/
117+
// https://gs.statcounter.com/screen-resolution-stats (note: logical. we want physical)
118+
// Include 1080 because lighthouse uses a moto g4 with 360 logical pixels and 3x pixel ratio.
119+
const widths = [540, 768, 1080, 1366, 1536, 1920, 2560, 3000, 4096, 5120];
120+
widths.push(width);
121+
return { widths, kind: 'w' };
122+
}
123+
124+
// Don't need more than 2x resolution. Note that due to this optimization, pixel density
125+
// descriptors will often end up being cheaper as many mobile devices have pixel density ratios
126+
// near 3 which would cause larger images to be chosen on mobile when using sizes.
127+
128+
// Most OLED screens that say they are 3x resolution, are actually 3x in the green color, but
129+
// only 1.5x in the red and blue colors. Showing a 3x resolution image in the app vs a 2x
130+
// resolution image will be visually the same, though the 3x image takes significantly more
131+
// data. Even true 3x resolution screens are wasteful as the human eye cannot see that level of
132+
// detail without something like a magnifying glass.
133+
// https://blog.twitter.com/engineering/en_us/topics/infrastructure/2019/capping-image-fidelity-on-ultra-high-resolution-devices.html
134+
return { widths: [Math.round(width / 2), width], kind: 'x' };
135+
}

0 commit comments

Comments
 (0)