Skip to content

Commit

Permalink
feat: static image preprocessor
Browse files Browse the repository at this point in the history
  • Loading branch information
benmccann committed Sep 26, 2023
1 parent 34f1ec5 commit 9c430e6
Show file tree
Hide file tree
Showing 15 changed files with 648 additions and 70 deletions.
85 changes: 85 additions & 0 deletions packages/image/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# `@sveltejs/image`

**WARNING**: This package is experimental. It uses pre-1.0 versioning and may introduce breaking changes with every minor version release.

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`.

## Setup

Install:

```bash
npm install --save @sveltejs/image
```

Adjust `vite.config.js`:

```diff
+import { images } from '@sveltejs/image/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [
+ images(),
sveltekit()
]
});
```

## Usage

Static build time optimization uses `vite-imagetools`, which comes as an optional peer dependency, so you first need to install it:

```bash
npm install --save-dev vite-imagetools
```

Use in your `.svelte` components by referencing a relative path beginning with `./` or `$` (for Vite aliases):

```svelte
<img src="./path/to/your/image.jpg" alt="An alt text" />
```

This optimizes the image at build time using `vite-imagetools`. `width` and `height` are optional as they can be inferred from the source image.

You can also manually import an image and then pass it to a transformed `img` tag.
```svelte
<script>
import { MyImage } from './path/to/your/image.jpg';
</script>
<!-- svelte-image-enable -->
<img src={MyImage} alt="An alt text" />
```

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).

> 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.
Note that the generated code is a `picture` tag wrapping one `source` tag per image type.

If you have an image tag you do not want to be transformed you can use the comment `<!-- svelte-image-disable -->`.

### Static vs dynamic image references

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.

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.

You can mix and match both solutions in one project, but can only use this library for static image references.

## Best practices

- Always provide a good `alt` text
- Your original images should have a good quality/resolution. Images will only be sized down
- 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)
- 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

## Roadmap

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.

## Acknowledgements

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`.
67 changes: 67 additions & 0 deletions packages/image/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"name": "@sveltejs/image",
"version": "0.1.0",
"description": "Image optimization for your Svelte apps",
"repository": {
"type": "git",
"url": "https://github.com/sveltejs/kit",
"directory": "packages/image"
},
"license": "MIT",
"homepage": "https://kit.svelte.dev",
"type": "module",
"scripts": {
"lint": "prettier --check . --config ../../.prettierrc --ignore-path .gitignore",
"check": "tsc",
"format": "prettier --write . --config ../../.prettierrc --ignore-path .gitignore",
"test": "vitest"
},
"files": [
"src",
"types"
],
"exports": {
"./package.json": "./package.json",
".": {
"types": "./types/index.d.ts",
"import": "./src/index.js"
},
"./vite": {
"import": "./src/vite-plugin.js"
}
},
"types": "types/index.d.ts",
"typesVersions": {
"*": {
"index": [
"types/index.d.ts"
],
"vite": [
"types/vite.d.ts"
]
}
},
"dependencies": {
"esm-env": "^1.0.0",
"magic-string": "^0.30.0",
"svelte-parse-markup": "^0.1.1"
},
"devDependencies": {
"@types/estree": "^1.0.2",
"@types/node": "^16.18.6",
"svelte": "^4.0.5",
"typescript": "^4.9.4",
"vite": "^4.4.2",
"vite-imagetools": "^5.0.8",
"vitest": "^0.34.0"
},
"peerDependencies": {
"svelte": "^4.0.0",
"vite-imagetools": "^5.0.8"
},
"peerDependenciesMeta": {
"vite-imagetools": {
"optional": true
}
}
}
189 changes: 189 additions & 0 deletions packages/image/src/preprocessor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import MagicString from 'magic-string';
import { parse } from 'svelte-parse-markup';
import { walk } from 'svelte/compiler';

const IGNORE_FLAG = 'svelte-image-disable';
const FORCE_FLAG = 'svelte-image-enable';
const ASSET_PREFIX = '___ASSET___';

// TODO: expose this in vite-imagetools rather than duplicating it
const OPTIMIZABLE = /^[^?]+\.(heic|heif|avif|jpeg|jpg|png|tiff|webp|gif)(\?.*)?$/;

/**
* @returns {import('svelte/types/compiler/preprocess').PreprocessorGroup}
*/
export function image() {
return {
markup({ content, filename }) {
const s = new MagicString(content);
const ast = parse(content, { filename });

// Import path to import name
// e.g. ./foo.png => ___ASSET___0
/** @type {Map<string, string>} */
const imports = new Map();

/**
* @param {import('svelte/types/compiler/interfaces').TemplateNode} node
* @param {{ type: string, start: number, end: number, raw: string }} attribute_value
*/
function update_element(node, attribute_value) {
if (attribute_value.type === 'MustacheTag') {
const src_var_name = content
.substring(attribute_value.start + 1, attribute_value.end - 1)
.trim();
s.update(node.start, node.end, dynamic_img_to_picture(content, node, src_var_name));
return;
}

const url = attribute_value.raw.trim();

// if it's not a relative reference or Vite alias then skip it
// TODO: read vite aliases here rather than assuming $
if (!url.startsWith('./') && !url.startsWith('$')) return;

let import_name = '';

if (imports.has(url)) {
import_name = /** @type {string} */ (imports.get(url));
} else {
import_name = ASSET_PREFIX + imports.size;
imports.set(url, import_name);
}

if (OPTIMIZABLE.test(url)) {
s.update(node.start, node.end, img_to_picture(content, node, import_name));
} else {
// e.g. <img src="./foo.svg" /> => <img src="{___ASSET___0}" />
s.update(attribute_value.start, attribute_value.end, `{${import_name}}`);
}
}

let ignore_next_element = false;
let force_next_element = false;

// @ts-ignore
walk(ast.html, {
/**
* @param {import('svelte/types/compiler/interfaces').TemplateNode} node
*/
enter(node) {
if (node.type === 'Comment') {
if (node.data.trim() === IGNORE_FLAG) {
ignore_next_element = true;
} else if (node.data.trim() === FORCE_FLAG) {
force_next_element = true;
}
} else if (node.type === 'Element') {
if (ignore_next_element) {
ignore_next_element = false;
return;
}

// Compare node tag match
if (node.name === 'img') {
/**
* @param {string} attr
*/
function get_attr_value(attr) {
const attribute = node.attributes.find(
/** @param {any} v */ (v) => v.type === 'Attribute' && v.name === attr
);
if (!attribute) return;

// Ensure value only consists of one element, and is of type "Text".
// Which should only match instances of static `foo="bar"` attributes.
if (
!force_next_element &&
(attribute.value.length !== 1 || attribute.value[0].type !== 'Text')
) {
return;
}

return attribute.value[0];
}

const src = get_attr_value('src');
if (!src) return;
update_element(node, src);
}
}
}
});

// add imports
if (imports.size) {
let import_text = '';
for (const [path, import_name] of imports.entries()) {
import_text += `import ${import_name} from "${path}";`;
}
if (ast.instance) {
// @ts-ignore
s.appendLeft(ast.instance.content.start, import_text);
} else {
s.append(`<script>${import_text}</script>`);
}
}

return {
code: s.toString(),
map: s.generateMap()
};
}
};
}

/**
* @param {string} content
* @param {Array<import('svelte/types/compiler/interfaces').BaseDirective | import('svelte/types/compiler/interfaces').Attribute | import('svelte/types/compiler/interfaces').SpreadAttribute>} attributes
* @param {string} src_var_name
*/
function attributes_to_markdown(content, attributes, src_var_name) {
const attribute_strings = attributes.map((attribute) => {
if (attribute.name === 'src') {
return `src={${src_var_name}.img.src}`;
}
return content.substring(attribute.start, attribute.end);
});

let has_width = false;
let has_height = false;
for (const attribute of attributes) {
if (attribute.name === 'width') has_width = true;
if (attribute.name === 'height') has_height = true;
}
if (!has_width && !has_height) {
attribute_strings.push(`width={${src_var_name}.img.w}`);
attribute_strings.push(`height={${src_var_name}.img.h}`);
}

return attribute_strings.join(' ');
}

/**
* @param {string} content
* @param {import('svelte/types/compiler/interfaces').TemplateNode} node
* @param {string} import_name
*/
function img_to_picture(content, node, import_name) {
return `<picture>
{#each Object.entries(${import_name}.sources) as [format, images]}
<source srcset={images.map((i) => \`\${i.src} \${i.w}w\`).join(', ')} type={'image/' + format} />
{/each}
<img ${attributes_to_markdown(content, node.attributes, import_name)} />
</picture>`;
}

/**
* For images like `<img src={manually_imported} />`
* @param {string} content
* @param {import('svelte/types/compiler/interfaces').TemplateNode} node
* @param {string} src_var_name
*/
function dynamic_img_to_picture(content, node, src_var_name) {
return `{#if typeof ${src_var_name} === 'string'}
<img ${attributes_to_markdown(content, node.attributes, src_var_name)} />
{:else}
${img_to_picture(content, node, src_var_name)}
{/if}`;
}

0 comments on commit 9c430e6

Please sign in to comment.