Image optimization
On this page
Drop a full-size image into src/ or public/, point an <img> at it, and sitelo resizes it, converts it to a modern format, and rewrites the tag with a srcset. Nothing to import, no component to learn.
Enable it
export default {
images: true,
}Encoding is done by sharp, an optional peer dependency — install it alongside sitelo when you turn images on:
npm install -D sharpSites that skip image optimization never need to install it.
Write a plain <img>
export default () => `
<html lang="en">
<body>
<img src="/images/hero.png" alt="Sunrise over the harbour">
</body>
</html>
`import { html, body, img } from 'javascript-to-html'
export default () =>
html({ lang: 'en' },
body(
img({ src: '/images/hero.png', alt: 'Sunrise over the harbour' }),
),
)export default function Home() {
return (
<html lang="en">
<body>
<img src="/images/hero.png" alt="Sunrise over the harbour" />
</body>
</html>
)
}A 3000×2000 source comes out the other side like this:
<img src="/assets/img/hero.a1b2c3d4-3000.webp"
alt="Sunrise over the harbour"
sizes="100vw"
width="3000" height="2000"
loading="lazy" decoding="async"
srcset="/assets/img/hero.9f8e7d6c-400.webp 400w,
/assets/img/hero.5b4a3c2d-800.webp 800w,
/assets/img/hero.7c6d5e4f-1200.webp 1200w,
/assets/img/hero.a1b2c3d4-3000.webp 3000w">A screen up to 400px wide gets the 400 file, up to 800 the 800, up to 1200 the 1200, and anything wider the full 3000 — the browser picks the first rung that covers the viewport (doubled on a 2× display).
What you get
- Resizing that never upscales — every configured width below the source’s, then the source’s own width on top. A 750px source with
widths: [400, 800, 1200]emits 400 and 750, and stops there. - Modern formats — one format gives a plain
<img srcset>; two or more wrap it in<picture>with a fallback in the original format. - No layout shift —
widthandheightare filled in from the real image, plusloading="lazy"anddecoding="async". - The same markup in dev —
sitelo(dev) rewrites pages and serves variants on demand, so you preview what you ship. - A shared cache — variants are keyed by content hash in
node_modules/.sitelo/images, so dev and rebuilds never encode the same image twice.
The rewrite runs over the built HTML, so it covers images from src/ and public/ alike.
Once a tag is rewritten nothing points at the original any more, so it is pruned from the build by default. Only genuinely unreferenced files go: every HTML, CSS, JS, XML and JSON file in the build is scanned, so an original linked from an <a href>, an og:image, an RSS enclosure, a CSS url() or a data-no-optimize tag stays. URLs assembled at runtime in a script, or rendered by a server island, are the ones it cannot see — set prune: false if you have those.
Options
export default {
images: {
widths: [400, 800, 1200],
formats: ['avif', 'webp'],
quality: { avif: 55, webp: 78, jpeg: 82 },
exclude: ['**/og/**'],
},
}widths— default[400, 800, 1200]; the source’s own width always tops the ladderformats— default['webp'];avif,webp,jpeg,pngquality— default{ avif: 55, webp: 78, jpeg: 82 }; per-format encoder quality (png uses compression, not quality)sizes— thesizesattribute; asizeson the tag always winsdimensions— defaulttrue; addswidth/heightlazy— defaulttrue; addsloadinganddecodingexclude— glob(s) or RegExp(s) of image URLs to leave aloneassetsDir— default'assets/img'cacheDir— default'node_modules/.sitelo/images'remote— defaultfalse; optimizehttps://images at build timeprune— defaulttrue; delete originals nothing in the build references any moredev— defaulttrue; setfalseto serve untouched originals in devconcurrency— parallel encodes, default CPUs − 1 (max 8); 1 when remote is on
Two formats give you <picture>
AVIF is smaller but younger than WebP, so listing both lets the browser pick and keeps a fallback for old ones:
<picture>
<source type="image/avif" srcset="/assets/img/hero.*-400.avif 400w, ..." sizes="...">
<source type="image/webp" srcset="/assets/img/hero.*-400.webp 400w, ..." sizes="...">
<img src="/assets/img/hero.*-3000.png" alt="..." srcset="..." width="3000" height="2000">
</picture>One size, by name
Carousel thumbnails, avatars, a grid of cards — some images are only ever shown at one size, and a whole ladder is wasted on them. Variant file names carry a content hash, so you cannot write them by hand; name the width on the URL instead and the tag comes out with that one file:
<img src="/images/hero.png?w=400" alt="Sunrise over the harbour">
<img src="/images/hero.png?w=400&format=jpeg" alt="Sunrise over the harbour">
<img src="/images/hero.png?w=200&h=200" alt="Sunrise over the harbour">
<img src="/images/hero.png?w=200&h=200&fit=contain" alt="Sunrise over the harbour">
<img src="/images/hero.png?w=200&h=200&background=fff" alt="Sunrise over the harbour">
<img src="/images/hero.png?w=200&h=200&position=top" alt="Sunrise over the harbour"><img src="/assets/img/hero.9f8e7d6c-400.webp"
alt="Sunrise over the harbour"
width="400" height="267"
loading="lazy" decoding="async">w— the width in pixels. Any width works, not only those inwidths; a smaller source keeps its own size rather than being upscaled.h— the height in pixels. Alone, the width follows the aspect ratio; withwas well the image is scaled to fill that box and cropped to it —?w=200&h=200is a square thumbnail.fit— how aw×hbox is met:cover(the default) fills it and crops;containscales the whole image to sit inside it, no cropping, no padding —width/heightsay what came out.background— pads acontainresult out to the exact box, and impliesfit=contain. Hex without the#(fff,1a1a1a,ffffff80), a CSS colour name, ortransparent— which needs a format with alpha; on jpeg it comes out black.position— which part acovercrop keeps. Centred by default; an edge or corner (top,left,right-bottom, …), or sharp’s content-awareentropy/attentionstrategies.format—avif,webp,jpegorpng. On its own it keeps the full ladder, in that one format.
With several formats configured, a pinned width still gives you <picture>, with one file per <source>. The names are the ones vite-imagetools uses. Remote URLs are never parsed — their query string belongs to the origin — and with images off, static hosts ignore the query and serve the original.
Letting the picture choose the crop
An edge or corner is predictable, but a carousel of mixed photos rarely has its subject in the same place twice. entropy and attention ask sharp to look at each picture and slide the crop window to where it finds something worth keeping:
<img src="/images/hero.png?w=300&h=300&position=entropy" alt="Sunrise over the harbour">
<img src="/images/team.jpg?w=300&h=300&position=attention" alt="The team">entropykeeps the region with the most detail — edges, texture, colour variation. Flat sky, blank walls and plain backgrounds go first. Good for landscapes, products, anything where “busy” means “interesting”.attentionweights the crop towards saturated colour, high-frequency luminance and skin tones — a heuristic for where a person would look. Good for photos of people: it tends to find faces and subjects.
Both are plain heuristics — no model, no training — so treat them as a sensible default for many images rather than a guarantee for one. For a hero image you care about, name the edge. The choice depends only on the picture, so it is deterministic and caches like any other variant.
Opting out
Tags that already have a srcset, sit inside a <picture>, or point at an SVG, an animated GIF, or a remote URL are left untouched. For anything else, say so:
<img src="/images/exact.png" alt="Pixel art" data-no-optimize>Social-card and favicon images live in <meta> and <link>, which this never rewrites — they keep their fixed URL.
Remote images
Content imported from a CMS often points at someone else’s server. Turn on remote and those images are downloaded, optimized, and served from your own domain:
export default {
images: {
remote: true,
},
}A fetch that fails leaves the tag exactly as it was, with a warning — a flaky origin never fails your build. prune then removes local originals that nothing references any more.