Skip to content

Commit 2010480

Browse files
committed
feat: static image preprocessor
1 parent 6bfcff2 commit 2010480

File tree

15 files changed

+647
-68
lines changed

15 files changed

+647
-68
lines changed

packages/image/README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# `@sveltejs/image`
2+
3+
**WARNING**: This package is experimental. It uses pre-1.0 versioning and may introduce breaking changes with every minor version release.
4+
5+
This package aims to bring a plug and play image component to SvelteKit that is opinionated enough so you don't have to worry about the details, yet flexible enough for more advanced use cases or tweaks. It serves a smaller file format like `avif` or `webp`.
6+
7+
## Setup
8+
9+
Install:
10+
11+
```bash
12+
npm install --save @sveltejs/image
13+
```
14+
15+
Adjust `vite.config.js`:
16+
17+
```diff
18+
+import { images } from '@sveltejs/image/vite';
19+
import { sveltekit } from '@sveltejs/kit/vite';
20+
import { defineConfig } from 'vite';
21+
22+
export default defineConfig({
23+
plugins: [
24+
+ images(),
25+
sveltekit()
26+
]
27+
});
28+
```
29+
30+
## Usage
31+
32+
Static build time optimization uses `vite-imagetools`, which comes as an optional peer dependency, so you first need to install it:
33+
34+
```bash
35+
npm install --save-dev vite-imagetools
36+
```
37+
38+
Use in your `.svelte` components by referencing a relative path beginning with `./` or `$` (for Vite aliases):
39+
40+
```svelte
41+
<img src="./path/to/your/image.jpg" alt="An alt text" />
42+
```
43+
44+
This optimizes the image at build time using `vite-imagetools`. `width` and `height` are optional as they can be inferred from the source image.
45+
46+
You can also manually import an image and then pass it to a transformed `img` tag.
47+
48+
```svelte
49+
<script>
50+
import { MyImage } from './path/to/your/image.jpg';
51+
</script>
52+
53+
<!-- svelte-image-enable -->
54+
<img src={MyImage} alt="An alt text" />
55+
```
56+
57+
This is useful when you have a collection of static images and would like to dynamically choose one. A collection of images can be imported with [Vite's `import.meta.glob`](https://vitejs.dev/guide/features.html#glob-import).
58+
59+
> If you have existing image imports like `import SomeImage from './some/image.jpg';` they will be treated differently now. If you want to get back the previous behavior of this import returning a URL string, add `?url` to the end of the import.
60+
61+
Note that the generated code is a `picture` tag wrapping one `source` tag per image type.
62+
63+
If you have an image tag you do not want to be transformed you can use the comment `<!-- svelte-image-disable -->`.
64+
65+
### Static vs dynamic image references
66+
67+
This package only handles images that are located in your project and can be referred to with a static string. It generates images at build time, so building may take longer the more images you transform.
68+
69+
Alternatively, using an image CDN provides more flexibility with regards to sizes and you can pass image sources not known at build time, but it comes with potentially a bit of setup overhead (configuring the image CDN) and possibly usage cost. CDNs reduce latency by distributing copies of static assets globally. Building HTML to target CDNs may result in slightly smaller 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. Finally some CDNs may generate images lazily, which could have a negative performance impact for sites with low traffic and frequently changing images.
70+
71+
You can mix and match both solutions in one project, but can only use this library for static image references.
72+
73+
## Best practices
74+
75+
- Always provide a good `alt` text
76+
- Your original images should have a good quality/resolution. Images will only be sized down
77+
- 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)
78+
- Give the image a container or a 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
79+
80+
## Roadmap
81+
82+
This is an experimental MVP for getting initial feedback on the implementation/usability of an image component usable with SvelteKit (can also be used with Vite only). Once the API is stable, we may enable in SvelteKit or the templates by default.
83+
84+
## Acknowledgements
85+
86+
We'd like to thank the authors of the Next/Nuxt/Astro/`unpic` image components and `svelte-preprocess-import-assets` for inspiring this work. We'd also like to thank the authors of `vite-imagetools` which is used in `@sveltejs/image`.

packages/image/package.json

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{
2+
"name": "@sveltejs/image",
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+
"./package.json": "./package.json",
25+
".": {
26+
"types": "./types/index.d.ts",
27+
"import": "./src/index.js"
28+
},
29+
"./vite": {
30+
"import": "./src/vite-plugin.js"
31+
}
32+
},
33+
"types": "types/index.d.ts",
34+
"typesVersions": {
35+
"*": {
36+
"index": [
37+
"types/index.d.ts"
38+
],
39+
"vite": [
40+
"types/vite.d.ts"
41+
]
42+
}
43+
},
44+
"dependencies": {
45+
"esm-env": "^1.0.0",
46+
"magic-string": "^0.30.0",
47+
"svelte-parse-markup": "^0.1.1"
48+
},
49+
"devDependencies": {
50+
"@types/estree": "^1.0.2",
51+
"@types/node": "^16.18.6",
52+
"svelte": "^4.0.5",
53+
"typescript": "^4.9.4",
54+
"vite": "^4.4.2",
55+
"vite-imagetools": "^5.0.8",
56+
"vitest": "^0.34.0"
57+
},
58+
"peerDependencies": {
59+
"svelte": "^4.0.0",
60+
"vite-imagetools": "^5.0.8"
61+
},
62+
"peerDependenciesMeta": {
63+
"vite-imagetools": {
64+
"optional": true
65+
}
66+
}
67+
}

packages/image/src/preprocessor.js

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import MagicString from 'magic-string';
2+
import { parse } from 'svelte-parse-markup';
3+
import { walk } from 'svelte/compiler';
4+
5+
const IGNORE_FLAG = 'svelte-image-disable';
6+
const FORCE_FLAG = 'svelte-image-enable';
7+
const ASSET_PREFIX = '___ASSET___';
8+
9+
// TODO: expose this in vite-imagetools rather than duplicating it
10+
const OPTIMIZABLE = /^[^?]+\.(heic|heif|avif|jpeg|jpg|png|tiff|webp|gif)(\?.*)?$/;
11+
12+
/**
13+
* @returns {import('svelte/types/compiler/preprocess').PreprocessorGroup}
14+
*/
15+
export function image() {
16+
return {
17+
markup({ content, filename }) {
18+
const s = new MagicString(content);
19+
const ast = parse(content, { filename });
20+
21+
// Import path to import name
22+
// e.g. ./foo.png => ___ASSET___0
23+
/** @type {Map<string, string>} */
24+
const imports = new Map();
25+
26+
/**
27+
* @param {import('svelte/types/compiler/interfaces').TemplateNode} node
28+
* @param {{ type: string, start: number, end: number, raw: string }} attribute_value
29+
*/
30+
function update_element(node, attribute_value) {
31+
if (attribute_value.type === 'MustacheTag') {
32+
const src_var_name = content
33+
.substring(attribute_value.start + 1, attribute_value.end - 1)
34+
.trim();
35+
s.update(node.start, node.end, dynamic_img_to_picture(content, node, src_var_name));
36+
return;
37+
}
38+
39+
const url = attribute_value.raw.trim();
40+
41+
// if it's not a relative reference or Vite alias then skip it
42+
// TODO: read vite aliases here rather than assuming $
43+
if (!url.startsWith('./') && !url.startsWith('$')) return;
44+
45+
let import_name = '';
46+
47+
if (imports.has(url)) {
48+
import_name = /** @type {string} */ (imports.get(url));
49+
} else {
50+
import_name = ASSET_PREFIX + imports.size;
51+
imports.set(url, import_name);
52+
}
53+
54+
if (OPTIMIZABLE.test(url)) {
55+
s.update(node.start, node.end, img_to_picture(content, node, import_name));
56+
} else {
57+
// e.g. <img src="./foo.svg" /> => <img src="{___ASSET___0}" />
58+
s.update(attribute_value.start, attribute_value.end, `{${import_name}}`);
59+
}
60+
}
61+
62+
let ignore_next_element = false;
63+
let force_next_element = false;
64+
65+
// @ts-ignore
66+
walk(ast.html, {
67+
/**
68+
* @param {import('svelte/types/compiler/interfaces').TemplateNode} node
69+
*/
70+
enter(node) {
71+
if (node.type === 'Comment') {
72+
if (node.data.trim() === IGNORE_FLAG) {
73+
ignore_next_element = true;
74+
} else if (node.data.trim() === FORCE_FLAG) {
75+
force_next_element = true;
76+
}
77+
} else if (node.type === 'Element') {
78+
if (ignore_next_element) {
79+
ignore_next_element = false;
80+
return;
81+
}
82+
83+
// Compare node tag match
84+
if (node.name === 'img') {
85+
/**
86+
* @param {string} attr
87+
*/
88+
function get_attr_value(attr) {
89+
const attribute = node.attributes.find(
90+
/** @param {any} v */ (v) => v.type === 'Attribute' && v.name === attr
91+
);
92+
if (!attribute) return;
93+
94+
// Ensure value only consists of one element, and is of type "Text".
95+
// Which should only match instances of static `foo="bar"` attributes.
96+
if (
97+
!force_next_element &&
98+
(attribute.value.length !== 1 || attribute.value[0].type !== 'Text')
99+
) {
100+
return;
101+
}
102+
103+
return attribute.value[0];
104+
}
105+
106+
const src = get_attr_value('src');
107+
if (!src) return;
108+
update_element(node, src);
109+
}
110+
}
111+
}
112+
});
113+
114+
// add imports
115+
if (imports.size) {
116+
let import_text = '';
117+
for (const [path, import_name] of imports.entries()) {
118+
import_text += `import ${import_name} from "${path}";`;
119+
}
120+
if (ast.instance) {
121+
// @ts-ignore
122+
s.appendLeft(ast.instance.content.start, import_text);
123+
} else {
124+
s.append(`<script>${import_text}</script>`);
125+
}
126+
}
127+
128+
return {
129+
code: s.toString(),
130+
map: s.generateMap()
131+
};
132+
}
133+
};
134+
}
135+
136+
/**
137+
* @param {string} content
138+
* @param {Array<import('svelte/types/compiler/interfaces').BaseDirective | import('svelte/types/compiler/interfaces').Attribute | import('svelte/types/compiler/interfaces').SpreadAttribute>} attributes
139+
* @param {string} src_var_name
140+
*/
141+
function attributes_to_markdown(content, attributes, src_var_name) {
142+
const attribute_strings = attributes.map((attribute) => {
143+
if (attribute.name === 'src') {
144+
return `src={${src_var_name}.img.src}`;
145+
}
146+
return content.substring(attribute.start, attribute.end);
147+
});
148+
149+
let has_width = false;
150+
let has_height = false;
151+
for (const attribute of attributes) {
152+
if (attribute.name === 'width') has_width = true;
153+
if (attribute.name === 'height') has_height = true;
154+
}
155+
if (!has_width && !has_height) {
156+
attribute_strings.push(`width={${src_var_name}.img.w}`);
157+
attribute_strings.push(`height={${src_var_name}.img.h}`);
158+
}
159+
160+
return attribute_strings.join(' ');
161+
}
162+
163+
/**
164+
* @param {string} content
165+
* @param {import('svelte/types/compiler/interfaces').TemplateNode} node
166+
* @param {string} import_name
167+
*/
168+
function img_to_picture(content, node, import_name) {
169+
return `<picture>
170+
{#each Object.entries(${import_name}.sources) as [format, images]}
171+
<source srcset={images.map((i) => \`\${i.src} \${i.w}w\`).join(', ')} type={'image/' + format} />
172+
{/each}
173+
<img ${attributes_to_markdown(content, node.attributes, import_name)} />
174+
</picture>`;
175+
}
176+
177+
/**
178+
* For images like `<img src={manually_imported} />`
179+
* @param {string} content
180+
* @param {import('svelte/types/compiler/interfaces').TemplateNode} node
181+
* @param {string} src_var_name
182+
*/
183+
function dynamic_img_to_picture(content, node, src_var_name) {
184+
return `{#if typeof ${src_var_name} === 'string'}
185+
<img ${attributes_to_markdown(content, node.attributes, src_var_name)} />
186+
{:else}
187+
${img_to_picture(content, node, src_var_name)}
188+
{/if}`;
189+
}

0 commit comments

Comments
 (0)