-
-
Notifications
You must be signed in to change notification settings - Fork 2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
15 changed files
with
649 additions
and
70 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
# `@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`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`; | ||
} |
Oops, something went wrong.