# Image optimization

> Automatic resizing, modern formats, and srcset for every <img> — in dev and in the build.

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

`sitelo.config.js`

```javascript
export default {
  images: true,
}
```

Encoding is done by [sharp](https://sharp.pixelplumbing.com), an optional peer dependency — install it alongside sitelo when you turn images on:

`shell`

```bash
npm install -D sharp
```

Sites that skip image optimization never need to install it.

## Write a plain <img>

`src/index.ht.js — Template literal`

```javascript
export default () => `
  <html lang="en">
    <body>
      <img src="/images/hero.png" alt="Sunrise over the harbour">
    </body>
  </html>
`
```

`src/index.ht.js — ht.js (recommended)`

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

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

`src/index.ht.jsx — JSX`

```jsx
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:

`dist/index.html`

```html
<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** — `width` and `height` are filled in from the real image, plus `loading="lazy"` and `decoding="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

`sitelo.config.js`

```javascript
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 ladder
- `formats` — default `['webp']`; `avif`, `webp`, `jpeg`, `png`
- `quality` — default `{ avif: 55, webp: 78, jpeg: 82 }`; per-format encoder quality (png uses compression, not quality)
- `sizes` — the `sizes` attribute; a `sizes` on the tag always wins
- `dimensions` — default `true`; adds `width` / `height`
- `lazy` — default `true`; adds `loading` and `decoding`
- `exclude` — glob(s) or RegExp(s) of image URLs to leave alone
- `assetsDir` — default `'assets/img'`
- `cacheDir` — default `'node_modules/.sitelo/images'`
- `remote` — default `false`; optimize `https://` images at build time
- `prune` — default `true`; delete originals nothing in the build references any more
- `dev` — default `true`; set `false` to serve untouched originals in dev
- `concurrency` — 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:

`dist/index.html`

```html
<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:

`src/index.ht.js`

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

`dist/index.html`

```html
<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 in `widths`; a smaller source keeps its own size rather than being upscaled.
- `h` — the height in pixels. Alone, the width follows the aspect ratio; with `w` as well the image is scaled to fill that box and cropped to it — `?w=200&h=200` is a square thumbnail.
- `fit` — how a `w`×`h` box is met: `cover` (the default) fills it and crops; `contain` scales the whole image to sit inside it, no cropping, no padding — `width`/`height` say what came out.
- `background` — pads a `contain` result out to the exact box, and implies `fit=contain`. Hex without the `#` (`fff`, `1a1a1a`, `ffffff80`), a CSS colour name, or `transparent` — which needs a format with alpha; on jpeg it comes out black.
- `position` — which part a `cover` crop keeps. Centred by default; an edge or corner (`top`, `left`, `right-bottom`, …), or sharp’s content-aware `entropy` / `attention` strategies.
- `format` — `avif`, `webp`, `jpeg` or `png`. 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:

`src/index.ht.js`

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

- **`entropy`** keeps 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”.
- **`attention`** weights 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:

`src/index.ht.js`

```html
<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:

`sitelo.config.js`

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