Image optimization

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 sharp

Sites that skip image optimization never need to install it.

Write a plain <img>

import { html, body, img } from 'javascript-to-html'

export default () =>
  html({ lang: 'en' },
    body(
      img({ src: '/images/hero.png', alt: 'Sunrise over the harbour' }),
    ),
  )

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

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/**'],
  },
}

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">

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">

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.