sitelo
v2.9.13
Published
Zero-config static site generation powered by Vite and vite-plugin-html-pages
Maintainers
Readme
sitelo
Static site generation for Vite — no framework, no components, no magic.
Write JavaScript (or TypeScript, or JSX) functions that return HTML. Get a complete static site with file-based routing, dynamic pages, data loading, an asset pipeline, sitemap, RSS, Pagefind search, and a live dev server.
⭐ If this project helps you, please consider starring it.
TL;DR
Write a function that returns HTML:
// src/index.ht.js
export default () => `
<html lang="en">
<head>
<title>My website</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<h1>Hello world</h1>
</body>
</html>
`Run:
sitelo buildGet:
dist/
index.html ← rendered, with <!DOCTYPE html> added for you
styles.css ← bundled + minified, because your page referenced it
404.html ← generated automaticallyThat's the whole mental model. Everything else is convenience on top.
Features
- File-based routing —
src/about.ht.js→/about - Dynamic routes —
[slug],[year]/[slug], catch-all[...path], optional catch-all[...path]? - Route groups —
(admin)/users.ht.js→/users - Bring your own HTML — template literals, javascript-to-html, or JSX/TSX
- sitelo-ui — 87 components (buttons, cards, forms, tables, modals, prose, page sections) and a 99-icon set, as functions returning HTML, with one small optional script
- Data loading —
data()runs at build time, with built-in fetch caching - Typed pages — per-route param types inferred from the filename
- Smart asset pipeline — JS/TS/CSS referenced by your HTML is bundled and minified; server-only code never leaks into
dist - Asset validation — broken
<script src>/ stylesheet links fail the build - Link checking — dead internal
<a href>links reported against the real output - Lighthouse audits — score the real build against per-category thresholds, on demand or in CI
- Unused-code detection — a one-line knip config that knows pages and islands are entry points
- Image optimization —
<img>tags get resized, converted, and turned into asrcset, in dev and in the build - Real dev server — pages render on request (dynamic routes included, no
generateStaticParamsneeded in dev) with full reload and readable error frames - Server islands — static pages with regions rendered on the server at request time, loaded eagerly, on idle, or on scroll, with optional signed props
- Parallel static generation — renders large sites concurrently
404.html,sitemap.xml, RSS, Pagefind — generated for you
Why this exists
Modern static site tools are powerful, but they bring frameworks, component systems, hydration strategies, and opinionated conventions.
Sometimes you just want to:
- write HTML
- organize pages in folders
- run
sitelo build
sitelo exists for exactly that. Pages are plain
functions that return a string of HTML. No runtime ships to the
browser unless you add a script.
Installation
npm install -D siteloRequires Node 20.19+ (or 22.12+). Vite is bundled — you don't install it separately.
Create pages in src/ and run:
sitelo # dev server with live rendering
sitelo build # static site in dist/
sitelo preview # preview the production build
sitelo lighthouse # audit the production buildConfiguration (optional)
Add a sitelo.config.js for plugin options and optional Vite settings:
// sitelo.config.js
export default {
site: 'https://example.com',
rss: {
site: 'https://example.com',
title: 'My Blog',
description: 'Latest posts',
routePrefix: '/blog',
},
// any Vite config goes under `vite`
vite: {
publicDir: 'static',
build: {
emptyOutDir: true,
outDir: 'public',
},
server: {
port: 8888,
},
},
}No vite.config.js is required. If you already have one, sitelo still
auto-loads it and merges your settings. CLI flags (e.g. --port) override
both.
Using with an existing Vite config
Prefer a separate Vite config file? You can still use vite.config.js —
either for Vite-only options (plugin stays auto-injected), or by registering
the plugin yourself:
// vite.config.js — Vite options only; sitelo still injects the plugin
export default {
publicDir: 'static',
server: { port: 8888 },
}// vite.config.js — full control, including the plugin
import htmlPages from "sitelo"
export default {
plugins: [htmlPages({
site: 'https://example.com',
})]
}Then you can run vite / vite build if you prefer. If the plugin is already
in your Vite config, put plugin options there — not also in sitelo.config.js
(sitelo will error if both register plugin options).
Add the generated helper types to your .gitignore:
.sitelo/Project structure
src/
index.ht.js → /
about.ht.js → /about
styles.css → bundled if referenced
main.js → bundled if referenced
lib/
api.js → build-time only (never emitted unless referenced)
blog/
index.ht.js → /blog
[slug].ht.js → /blog/:slug
docs/
[...path]?.ht.js → /docs, /docs/a, /docs/a/b, ...
(admin)/
users.ht.js → /users
404.ht.js → dist/404.htmlAny file ending in a page extension is a page. Everything else in
src/ is treated as an asset (see Assets).
Default page extensions: .ht.js, .html.js, .ht.ts, .html.ts,
.ht.jsx, .html.jsx, .ht.tsx, .html.tsx.
Writing pages
A page module's default export can be any of the following.
1. A function returning an HTML string
export default ({ params, data, dev }) => `
<html>
<body><h1>Hello</h1></body>
</html>
`2. A plain string
export default `<html><body><h1>Static as it gets</h1></body></html>`3. A structured module
Keeps render, data, and generateStaticParams together in one object:
export default {
generateStaticParams: () => [{ slug: "hello" }],
data: ({ params }) => ({ title: params.slug }),
render: ({ data }) => `<html><body><h1>${data.title}</h1></body></html>`,
}4. JSX / TSX
Name the file *.ht.jsx or *.ht.tsx and return JSX — it is rendered
to static HTML at build time with react-dom/server:
// src/index.ht.tsx
export default function Home() {
return (
<html lang="en">
<head><title>My site</title></head>
<body><h1>Hello from TSX</h1></body>
</html>
)
}JSX pages require react and react-dom in your project (they are
optional peer dependencies — string-based pages don't need them).
This is build-time rendering only. React is designed for the browser —
mounting, hydration, events — and sitelo does not ship a React runtime
with your pages. It renders JSX to a static HTML string, writes the
file, and stops. Event-handler props like onClick won't do anything
in the browser (the dev server warns if it finds any); hooks and
window/document are not available as a client app would expect.
For interactivity, add a normal client script or a server island.
5. javascript-to-html
Prefer composable functions over template strings? The companion library javascript-to-html works great:
import { html, head, title, body, h1 } from 'javascript-to-html'
export default () =>
html({ lang: 'en' },
head(title('My website')),
body(h1('Hello world'))
)If a page's output starts with
<html>,<!DOCTYPE html>is prepended automatically.
Render context
Every page function receives one argument:
| Property | Type | Description |
|----------|------|-------------|
| params | Record<string, string \| string[]> | Route params for this page |
| data | unknown | Whatever your data() returned |
| page | object | Route metadata (routePath, relativePath, ...) |
| dev | boolean | true in the dev server, false at build |
Routing
Routes come straight from the filesystem:
| Feature | File | URL |
|---------|------|-----|
| Static routes | index.ht.js | / |
| Nested routes | blog/index.ht.js | /blog |
| Dynamic routes | blog/[slug].ht.js | /blog/my-post |
| Multiple params | blog/[year]/[slug].ht.js | /blog/2026/my-post |
| Catch-all | docs/[...path].ht.js | /docs/api/auth/login |
| Optional catch-all | docs/[...path]?.ht.js | /docs and /docs/anything/below |
| Index routes | products/[id]/index.ht.js | /products/iphone-18 |
| Route groups | (admin)/users.ht.js | /users |
More specific routes always win: static segments beat dynamic ones, dynamic beat catch-alls. Two files generating the same URL is a build error, not a silent overwrite.
Static params
Dynamic routes declare their pages by exporting generateStaticParams:
// src/blog/[slug].ht.js
export function generateStaticParams() {
return [
{ slug: 'hello-world' },
{ slug: 'my-first-post' },
]
}
export default ({ params }) => `
<html><body><h1>${params.slug}</h1></body></html>
`Values can be strings, numbers, or booleans — they are stringified and
URL-encoded for you. Catch-all params accept arrays ({ path: ['a', 'b'] })
or slash-separated strings ({ path: 'a/b' }).
A dynamic page that generates zero routes prints a warning so it can't silently vanish from your site.
Data loading
Export a data() function and its result appears as ctx.data in your
render function. It runs at build time (and per-request in dev):
export async function data({ params, dev }) {
const res = await fetch(`https://api.example.com/posts/${params.slug}`)
return await res.json()
}
export default ({ data }) => `
<html><body>
<h1>${data.title}</h1>
${data.body}
</body></html>
`fetchWithCache
Building 500 pages against the same API? Cache the responses:
import { fetchWithCache } from 'sitelo'
export async function data({ params }) {
const res = await fetchWithCache(
`https://api.example.com/posts/${params.slug}`,
{ /* standard fetch options */ },
{ maxAge: 3600 }
)
return { post: await res.json() }
}| Option | Description |
|--------|-------------|
| maxAge | Cache TTL in seconds (default: 3600) |
| cacheKey | Custom cache key (default: hash of URL + method + headers + body) |
| forceRefresh | Bypass the cache and fetch fresh |
| cache | 'auto' | 'memory' | 'fs' | 'none' |
Cache modes:
auto(default) — memory in dev, filesystem in production buildsmemory— in-process, cleared when the process exitsfs— persisted innode_modules/.cache/sitelo/fetch/none— always fetch
Only GET requests are cached by default (pass a cacheKey to cache
other methods), and error responses are never cached — a flaky API
during one build won't poison the next one.
Local JSON files
No API? Keep the content in the repo as JSON and read it with
sitelo/data:
my-site/
├─ data/
│ ├─ site.json
│ └─ posts/
│ ├─ hello-world.json
│ └─ why-static.json
└─ src/
├─ index.ht.js
└─ blog/[slug].ht.js// src/blog/[slug].ht.js
import { readJsonCollection } from 'sitelo/data'
const posts = () => readJsonCollection('data/posts', { sort: '-date' })
export async function generateStaticParams() {
return (await posts()).map((post) => ({ slug: post.slug }))
}
export async function data({ params }) {
return (await posts()).find((post) => post.slug === params.slug)
}
export default ({ data }) => `
<html><body>
<h1>${data.title}</h1>
<time datetime="${data.date}">${data.date}</time>
${data.body}
</body></html>
`Relative paths resolve from the project root, so data/posts means the
same thing whether you run sitelo in the site directory or point
--root at it. Absolute paths and file: URLs work too — use
new URL('../data/', import.meta.url) to anchor at the page module.
| Function | Returns |
|----------|---------|
| readJson(source, options?) | The parsed contents of one .json file |
| readJsonCollection(source, options?) | An array of entries, each with a slug |
readJsonCollection takes either a directory of .json files — one
file per entry, slug from the filename — or a single file holding an
array of entries or an object keyed by slug:
// data/posts/hello-world.json → { slug: 'hello-world', … }
await readJsonCollection('data/posts')
// data/posts.json: [{ "slug": "hello-world", … }]
// data/posts.json: { "hello-world": { … } }
await readJsonCollection('data/posts.json')| Option | Description |
|--------|-------------|
| slug | Field name, or a function of the entry, to slug by. Defaults to the filename, the object key, or the entry's own slug / id |
| sort | Field name — 'date' ascending, '-date' descending — or a compare function |
| recursive | Read .json files in subdirectories too, slugged by their path (guide/intro) |
| root | Directory relative paths resolve from |
| cache | 'auto' | 'memory' | 'none' |
Reads are memoized per file, so a 500-page build parses each file once.
Under sitelo build the memo is kept outright; in the dev server it is
revalidated against mtime, and editing a JSON file a page read reloads
the browser. A collection is a fresh array on every call (sorting it is
safe), but the parsed objects themselves are shared between callers —
copy one before mutating it.
Two entries claiming the same slug is a build error rather than a silent overwrite, as is a missing file or malformed JSON, both named by path.
A full catalogue built this way — all three source shapes, one page per
file — is in examples/json.
TypeScript & typed params
Pages can be written in TypeScript (.ht.ts / .ht.tsx) with zero
configuration.
Helper functions give your page modules full type inference:
// src/blog/[slug].ht.ts
import { definePageModule } from 'sitelo/page'
export default definePageModule({
generateStaticParams: () => [{ slug: 'hello' }],
data: ({ params }) => ({ title: params.slug }),
render: ({ data }) => `<html><body><h1>${data.title}</h1></body></html>`,
})Individual helpers (definePage, defineData, defineStaticParams)
are also exported. At build time this import is transparently swapped
for a per-route generated module whose PageParams are inferred
from the filename: [slug] → { slug: string }, [...path] →
{ path: string[] }, [...path]? → { path?: string[] }.
Matching type declarations are generated into
.sitelo/types/ whenever the dev server or a build
runs — add that folder to .gitignore.
Components (sitelo-ui)
sitelo/ui is a component library for sitelo — buttons, cards, forms,
tables, tabs, modals and the rest. Every component is a function that
returns a string of HTML, so it nests straight into a
javascript-to-html
tree with nothing in between. No compiler, no runtime, no hydration.
// src/index.ht.js
import { body, head, html, title } from 'javascript-to-html'
import { styles, container, stack, heading, text, button } from 'sitelo/ui'
export default () =>
html({ lang: 'en' },
head(title('My site'), styles()),
body(
container({ size: 'md' },
stack({ gap: 'md' },
heading({ level: 1 }, 'Hello'),
text({ variant: 'lead' }, 'A page built from components.'),
button({ href: '/docs' }, 'Read the docs'),
),
),
),
)styles() returns a <link> to one file the browser caches across every
page:
head(title('My site'), styles())
// <link rel="stylesheet" href="/su/ui-c9428b65.css">There is nothing to copy and nothing to configure: sitelo's plugin serves
the file in dev and writes it into the build, at the same base as the
component runtime, under a name carrying a hash of the contents so you
can serve it immutable.
{ inline: true } puts the whole sheet in a <style> instead (~11 kB
gzipped per page). That costs no round trip and cannot go missing from
dist/, which is the better trade for a single page; the link buys its
request back on the second page a visitor reads.
The sheet covers every component in the library, and a site uses a
handful. pruneCss writes only the rules the build can match:
// sitelo.config.js
export default {
pruneCss: true,
}The plugin reads the su- classes off the pages it has just written —
and off the scripts beside them, since the toast runtime and the steps
runtime add classes of their own — and drops every rule whose selector
names a class nothing carries. This is decided from the output rather
than the imports, which is what makes it precise: button({ variant:
'soft' }) renders su-btn--soft and a plain button() does not, and
import * as ui says nothing about either. A linked sheet is pruned
against the whole site and gets a new hash for its new contents, with
every <link> rewritten to match; an inlined one is pruned to its own
page. A site using a dozen components lands around 2–3 kB gzipped.
It runs on the build only — dev serves the whole sheet. Markup the build never sees, such as a server island's, needs its classes named:
pruneCss: { keep: ['su-card', 'su-card-body', 'su-btn*'] } // `*` for a prefixThe rest of the family hands you the pieces: stylesheet() is the raw
CSS as a string — for hosting the sheet where sitelo cannot reach, or
writing it somewhere yourself — and stylesUrl() the href alone, for a
<link> of your own carrying media or integrity, a rel="preload"
hint, or a CSP. Like every asset URL in a page it is written for the
site root: under a Vite base, the dev server and the build put the
base on a <link> for you, and a CSP needs it put in front by hand.
Props
Every component takes an optional props object followed by children, exactly like a javascript-to-html element. Props the component understands are consumed by name; everything else falls through to the rendered element as an attribute:
button({ variant: 'soft', color: 'danger', id: 'del', 'data-page': slug }, 'Delete')
// <button type="button" id="del" data-page="…" class="su-btn su-btn--soft … su-c-danger">Component names match what they render, which means a few of them
(button, input, table, link, code, select, progress)
collide with javascript-to-html's element functions. Import the library
as a namespace when you need both:
import * as ui from 'sitelo/ui'Theming
Everything is driven by CSS custom properties — five palettes, a
spacing scale, radii, type and shadows. theme() overrides them, and
dark mode resolves from prefers-color-scheme unless data-theme or
data-su-theme says otherwise:
head(
styles(),
theme({
primary: { base: '#5b5bd6', hover: '#4a4ac4', fg: '#fff' },
radiusMd: '2px',
}, {
dark: { primary: { base: '#8f8ff0' } },
}),
)A preset changes the whole look at once. styles({ preset }) links a
second sheet straight after the core one, served and hashed on the same
terms, and theme() still works on top of it:
head(styles({ preset: 'neumorphism' }))neumorphism is soft UI — controls raised from the page or pressed into
it by light and shade — with text kept at WCAG AA and the focus outline
kept on. It needs the page's background to be var(--su-bg).
neubrutalism is flat colour and thick ink — every surface outlined,
hard shadows with no blur, and controls that press down into their own
shadow. In dark mode the ink turns cream. --su-nb-ink and
--su-nb-lift set the line colour and how far a control stands off the
page.
superneon is near-black violet with hairline edges and neon light —
solid buttons are dark pills ringed and haloed in a gradient, the big
headings are lit from above, and anything chosen glows. Light mode keeps
the dark pills on a pale lavender page. Each palette gains glow and
glowEnd, the two ends of that gradient, and var(--su-sn-backdrop)
is the page's ground with a violet light falling on it.
terminal is a system console — one monospace face on black, hairline
panels with their header ruled off, and labels in capitals, most of them
bold. Cyan is the accent, and a selection — a hovered row, a pressed
segment, a chosen pill, the current page — is printed in reverse, dark on
cyan; success, warning and danger are status colours, and an outline
button or a tag is drawn in its own. Nothing but a radio is rounded, and
nothing is shadowed. Light mode prints it in black on near-white.
--su-tm-tracking sets how far apart a label's capitals are,
--su-tm-field the line round a field, and
body { background: var(--su-bg); color: var(--su-text); font-family: var(--su-font-mono) }
sets the page in it too. iconSet('square') draws the icons to match.
blueprint is a technical drawing — hairlines on a near-black sheet,
square corners, and a registration mark on each corner of a card, a
dialog and a row of stats. Labels (buttons, field labels, tabs, tags,
column heads, bar links) are set in monospace capitals, spaced apart;
headings and running text stay in a grotesk, set tight. A solid line is
an edge and a dashed one divides what is inside it. Few colours: the
solid button and anything chosen are ink, printed in reverse, one
blueprint blue marks focus, and success, warning and danger keep theirs.
Nothing is shadowed. Light mode prints it in ink on white. primary is
the ink; --su-bp-accent sets the blue, --su-bp-mark the marks
(transparent removes them) and --su-bp-field the line round a field,
and body { background: var(--su-bp-backdrop) } rules a faint grid
across the page. iconSet('square') draws the icons to match.
Icons
icon() returns an inline <svg> from a set of 99 glyphs, drawn on one
24x24 grid in currentColor, so an icon takes the colour and the font
size of whatever it sits in:
button({ color: 'danger' }, icon('trash'), 'Delete')
iconButton({ label: 'Close', icon: icon('close') })
text(icon('check', { size: 'sm' }), ' Done')Icons are inline for the same reason styles({ inline: true }) is: there
is no file to emit, no base path to point at, and no second request
before the page is legible. A sprite referenced with <use> would save on the order of a
hundred gzipped bytes of HTML per page and cost a round trip to do it —
repeated markup is exactly what gzip is best at, so most of what a
sprite exists to dedupe has been deduped already.
filled paints a glyph rather than outlining it:
icon('heart', { filled: liked })It is a prop rather than a second set of names (heart-filled) because
the filled state is nearly always a state — saved, liked, rated — so a
boolean maps onto it directly. It is also the same path either way, with
only the fill attribute changing, so the two forms share an outer edge
and cannot drift apart.
The status glyphs fill too, but their mark sits inside the shape, so
painting the circle would swallow it. Those carry a second drawing — the
shape solid with the mark cut back out by fill-rule: evenodd — whose
outer edge matches the outline's, so the two forms still share a
silhouette. It is the same prop either way:
icon('check-circle', { filled: true }) // solid disc, tick knocked outfillableIcons() lists everything that answers to filled. A glyph
without a filled form ignores it and stays outlined, rather than
rendering something broken — filling eye would lose the pupil and tag
its hole, so neither pretends to.
An icon is aria-hidden unless you give it a label, which is right
whenever adjacent text already says what it means. Label it when the
icon is the whole of the meaning:
icon('trash', { label: 'Delete' }) // role="img", announcedNames describe the drawing rather than the job — x-circle, not
error — because the same drawing gets used for unrelated jobs. Common
intents are aliased, so icon('danger'), icon('error') and
icon('x-circle') are the same glyph. iconNames() lists them all.
There are two sets of the same glyphs. round is the default; square
redraws every glyph but the brand marks in straight lines — horizontal,
vertical or at 45° — with square ends, sharp corners and a thinner
stroke, a circle squared off or cut to an octagon. It is drawn for the
blueprint and terminal presets. iconSet() chooses one for every
icon drawn after it, the ones components draw for themselves included,
and hands back the set it replaced; call it once, from a module your
pages import:
import { iconSet } from 'sitelo/ui'
iconSet('square')The names, aliases and filled forms are the same in both, so no call
changes. The square spinner is a braille cell rather than a ring: under
spin its dots run round it the way a terminal's spinner does —
⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ — instead of the glyph turning.
Eight brand marks come with the set — facebook, google, instagram,
linkedin, tiktok, whatsapp, x-twitter and youtube. They break
two of the rules above on purpose: they are solid shapes rather than
strokes, which is what a logo is, and their proportions are the brand's
rather than this grid's. It is x-twitter and not x, because x
already aliases close.
The artwork is from Simple Icons, which releases it under CC0. That covers the drawing, not the trademark — use these to point at the thing they name, a profile link or a share button, and not on a product of your own.
Bring your own, or replace a built-in, with the inside of an <svg>
drawn on the same grid:
registerIcons({ logo: '<path d="M4 20 12 4l8 16z"/>' })
icon('logo')JavaScript
Most components need none. Modals and drawers are <dialog> elements
that a button opens with commandfor and command="show-modal", so the
browser handles the backdrop, keeping focus inside, Escape and a click
outside — button({ commandfor: 'confirm', command: 'show-modal' }). In
a browser from before those commands, button() adds a fallback that
loads /su/dialog.js there and nowhere else. (They used to be popovers
opened with popovertarget, which no longer opens them; sitelo dev
and sitelo build warn about any trigger still written that way.)
Accordions are <details name>, menus are <details>, tooltips are
CSS, and tabs whose panels swap in place are a radio group — the tab is
a <label>, the panel that follows the checked radio is the one CSS
shows, and arrow keys move between them because a radio group already
does. Give those items a fragment href as well and the radios become
links, with :target picking the tab and the panel after it, so the
choice is in the URL and the back button works.
Four things want a script — the close button on a dismissible alert,
closing a menu on an outside click or Escape, the theme toggle, and the
<output> on a slider that shows its value — and each one goes and
gets its own. The import is the event attribute:
<!-- rendered by alert({ dismissible: true }) -->
<button class="su-alert-dismiss"
onclick="import('/su/alert.js').then(m=>m.dismiss(this))">
×
</button>So there is nothing to add to an entry file. sitelo's plugin serves
those modules from /su/ in dev, and copies the ones your pages
reference — plus the handful of lines they share between them — into
the build; each is well under a kilobyte, none is fetched before the
first interaction, and every component renders correctly until it is —
menus open and close on their own, the dismiss button does nothing.
A sub-path deploy needs nothing more: /su/ is under Vite's base, so
a GitHub Pages project site built with --base /repo/ links and
imports /repo/su/…, and the build writes the files to dist/su/,
which is where /repo/ serves them from.
To serve them from somewhere else, uiClient.base is the URL the
browser asks for. A path from the root includes Vite's base — on that
same site, /repo/assets/su/ is written to dist/assets/su/ — and an
absolute URL is yours to host:
// vite.config.js
sitelo({ uiClient: { base: '/assets/su/' } })The rest are things nothing on the page triggers for you: state that is the site's, changing because the site says so. Import those, along with the theme helpers, from a bundled entry:
// src/main.js
import { setBadge, setPressed, setProgress, setSlide, setSlider, setStep, toast } from 'sitelo/ui/client'
request.upload.addEventListener('progress', (event) => {
setProgress('upload', event.loaded, { max: event.total })
})Each takes the element or the id you gave it, and moves everything the
server rendered together — the drawing, the announced value, and the
text beside it:
| Call | What it changes | Reads back |
| --- | --- | --- |
| toast(message) | Appends to the region toasts() rendered. | — |
| setProgress(bar, value) | The fill, aria-valuenow, and the percentage by the label. null returns it to the indeterminate animation. | getProgress(bar) — the value on the bar's own scale, null while it is indeterminate. |
| setBadge(badge, count) | The count, clamped to max+, with the announced text; an emptied badge leaves the accessibility tree. | getBadge(badge) — the count; a clamped one reads back as '99+'. |
| setPressed(button, on?) | aria-pressed, letting go of the siblings in a single-choice toggleGroup(). Omit on to flip it. | getPressed(button) — true or false, or a group's pressed values. |
| setSlider(slider, value) | The thumb and the <output> beside it, then input and change so a page listening for the drag hears it. Clamped to min/max and snapped to step. | getSlider(slider) — where the thumb stands. |
| setSlide(carousel, index) | Scrolls a carousel() to one of its slides and marks the dot for it. Where the browser draws the dots itself, it marks its own. | getSlide(carousel) — the index of the slide showing. |
| setStep(flow, index) | Every step at once — complete, current, upcoming — and the one aria-current. | getStep(flow) — the index in progress, or the count once they are all done. |
| setTheme(value) | The theme, remembered in localStorage. | getTheme() — 'light' or 'dark', resolving prefers-color-scheme when nothing was chosen. |
Every reader that takes a target answers null when the page has no
such element, so it doubles as the check for whether the thing is there
at all — getTheme() is the exception, since a page always has a theme.
Each is reachable from an event attribute too, the way the components
reach theirs, with nothing bundled:
import('/su/progress.js').then(m=>m.set('upload',100)).
What is in it
| Group | Components |
| --- | --- |
| Layout | container stack grid divider aspectRatio card cardHeader cardTitle cardSubtitle cardMedia cardBody cardFooter |
| Typography | text heading link code kbd visuallyHidden prose |
| Inputs | button iconButton buttonGroup field input textarea select slider textField textareaField selectField sliderField checkbox radio toggle toggleButton toggleGroup choiceGroup |
| Data display | avatar avatarGroup badge chip tooltip table list listItem figure carousel |
| Feedback | alert empty progress skeleton toasts |
| Navigation | breadcrumbs pagination tabs appBar appBarNav appBarSpacer appBarActions navLink themeToggle |
| Overlays | modal drawer closeButton menu menuItem menuSeparator accordion accordionItem collapsible |
| Sections | hero footer siteFooter footerColumn footerBottom stat statGroup steps timeline timelineItem mockup |
| Icons | icon iconNames fillableIcons hasIcon registerIcons — 99 glyphs |
| Styling | styles stylesheet stylesUrl theme themeScript |
The switch is toggle, because switch is a reserved word and cannot
be an import binding — and toggleButton is a different thing, a button
that stays pressed. link, table, input, code, select, progress
and footer each have a non-colliding alias — textLink, dataTable,
textInput, inlineCode, selectField, progressBar, siteFooter.
Every export ships type declarations, so an editor completes variant,
color and size in JavaScript as well as TypeScript.
examples/ui renders the whole set on one page, and
sitelo.dev/ui is the component gallery — one
page per component, each example rendered by the build that renders the
page around it.
Extras
Not everything belongs in one stylesheet. The components that are not
for everyone — textures and effects, a film grain to start — live under
a second entry point, sitelo/ui-extras, and each brings a sheet of its
own, so a page links only what it uses:
import { styles, container, hero } from 'sitelo/ui'
import { grain, grainStyles } from 'sitelo/ui-extras'
head(styles(), grainStyles())
body(grain({ as: 'section' }, container(hero({ title: 'Hello' }))))grainStyles() is styles() for grain.css — the same inline,
base, hash and nonce options, served in dev and written into the
build by the same plugin, from the same base. grainStylesheet() and
grainStylesUrl() are the bytes and the href on their own. The
page-side calls — setGrain() and getGrain() — are on
sitelo/ui-extras/client, in the same shape as sitelo/ui/client.
sitelo.dev/ui-extras catalogues them.
Assets & styling
Reference assets from your HTML with root-relative URLs and the plugin handles the rest:
export default () => `
<html>
<head>
<link rel="stylesheet" href="/styles.css">
<script type="module" src="/main.ts"></script>
</head>
<body>...</body>
</html>
`At build time:
- Referenced JS / TS / CSS is bundled with esbuild — imports are inlined and output is minified.
- TypeScript is referenced by its source name. Write
src="/main.ts"; the build emitsmain.jsand rewrites the URL in the generated HTML to match (.mjslikewise becomes.js). Code samples inside<pre>and<code>keep the source name. - Unreferenced code files are not emitted. A helper like
src/lib/api.tsthat you only import fromdata()stays out ofdist/— server-only code (and its secrets) never ships by accident. - Everything else is copied (images, fonts, videos, ...), so CSS
url()references keep working. public/behaves like normal Vite — copied verbatim.
In dev, the same URLs are served through Vite's transform pipeline, so TypeScript and CSS work identically without a build.
Relative URLs resolve the way the browser resolves them: against the
page's own location (or its <base href>). On src/blog/[slug].ht.js,
built to blog/hello/index.html, href="styles.css" means
/blog/hello/styles.css — not src/styles.css. Use root-relative URLs
for anything shared between pages.
Missing-asset validation
Every generated page is checked: a <script src="/x.js"> or stylesheet
href pointing at a file that exists in neither src/ nor public/
fails the build with the exact paths that were checked. Relative
URLs are checked where they resolve from that page, and the error
suggests the root-relative URL when that is what was meant — or the
.ts source, when a page asks for the .js it compiles to. Prefer a
warning instead?
// sitelo.config.js
export default {
missingAssets: 'warn',
}Internal link checking
Assets are checked against your source tree; links are checked against what the build actually emitted. Turn it on:
// sitelo.config.js
export default {
linkCheck: true, // 'warn' (default), 'error', or an options object
}Every internal <a href> in dist/ is resolved the way a static host
would resolve it, and anything with no page behind it is reported,
grouped by the page it appears on:
[sitelo] 3 broken internal links
index.html
../escape -> escapes the output directory
/abuot -> no such page
/blog/missing-post -> no such pageBecause it runs against the output rather than the route table, it
accounts for cleanUrls, route groups, mapOutputPath, files copied
from public/, and pages generated by dynamic routes. It also runs after
image optimization and Pagefind, so it sees exactly what ships. A link is
valid when a real file answers it, tried in the order a static host
would:
| Link | Resolved against |
|------|------------------|
| /about | about, then about/index.html, then about.html |
| /blog/ | blog/index.html — a trailing slash only ever means a directory index |
| / | index.html |
Relative links (../about) resolve against the page holding them, and
one that climbs out of the output directory is reported.
External links are never fetched. https://, protocol-relative //,
mailto:, tel: and friends are skipped, as are query strings
(/about?utm=x checks /about).
| Option | Default | Description |
|--------|---------|-------------|
| mode | 'warn' | 'warn' logs and continues; 'error' fails the build |
| exclude | [] | Globs or RegExps of hrefs to skip |
| checkFragments | false | Also verify #fragment targets exist in the linked page |
export default {
linkCheck: {
mode: 'error', // fail CI on a dead link
checkFragments: true,
exclude: ['/api/**', /^\/legacy\//],
},
}checkFragments is off by default because ids added by client-side
JavaScript aren't in the HTML, and would be reported as missing.
On a site with a base, a root-relative link that doesn't carry the
base (/about on a site served from /repo/) is reported: the browser
would leave your site entirely. Use exclude if that's deliberate.
Image optimization
Point <img> at a big source image and let sitelo do the rest:
// sitelo.config.js
export default {
images: true,
}// src/index.ht.js
export default () => `<img src="/images/hero.png" alt="Sunrise">`A 3000×2000 PNG becomes a resized, modern-format ladder topped by the full-size picture, and the tag is rewritten in place:
<img src="/assets/img/hero.a1b2c3d4-3000.webp"
alt="Sunrise"
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).
Encoding is done by sharp, an optional
peer dependency — install it alongside sitelo when you enable images:
npm install -D sharp # or: pnpm add -D sharp / yarn add -D sharpIt stays out of the install for sites that don't optimize images. npm, pnpm, Yarn (Classic and Berry, including Plug'n'Play) and Bun all work — sitelo declares sharp as an optional peer dependency, so your copy is the one it resolves.
What it does
- Resizes to each configured width below the source's, then adds the
source's own width on top — and never upscales. A 750px source with
widths: [400, 800, 1200]emits 400 and 750, nothing more. - Converts to modern formats. One format gives you a plain
<img srcset>; two or more wrap it in<picture>with a<source>per format and a fallback in the original format. - Adds
width/heightso the page doesn't shift while images load, plusloading="lazy"anddecoding="async". - Works in dev too.
sitelo devrewrites pages the same way and serves variants from the cache, so what you see is what you ship. - Caches by content hash in
node_modules/.sitelo/images. Rebuilds and dev share it, so nothing is encoded twice.
Images in both src/ and public/ are covered — the rewrite runs over the
built HTML, so it doesn't matter where the file came from. A relative
src resolves against its own page, the way the browser resolves it.
Once a tag is rewritten nothing points at the original any more, so it is
pruned from dist/ by default. Only the originals a tag was rewritten
from are candidates — an apple-touch-icon.png or a web manifest's icons,
which no <img> names, are never touched — and one is kept if its file
name appears anywhere in the build's HTML, CSS, JS, JSON, XML, web
manifest, feed, text or SVG files, as written or percent-encoded. So an
original linked from <a href> (relative or not), 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 (or exclude that folder) if you have
those.
Options
// sitelo.config.js
export default {
images: {
widths: [400, 800, 1200],
formats: ['avif', 'webp'],
quality: { avif: 55, webp: 78, jpeg: 82 },
exclude: ['**/og/**'],
},
}| Option | Default | Description |
|--------|---------|-------------|
| widths | [400, 800, 1200] | Ladder of widths; the source's own width always tops it |
| formats | ['webp'] | avif, webp, jpeg, png. 2+ formats → <picture> |
| quality | { avif: 55, webp: 78, jpeg: 82 } | Per-format encoder quality |
| sizes | derived | sizes attribute; a sizes on the tag always wins |
| dimensions | true | Add width/height (completes whichever you left off) |
| lazy | true | Add loading="lazy" and decoding="async" |
| exclude | [] | Glob(s) or RegExp(s) of image URLs to leave alone |
| assetsDir | 'assets/img' | Where variants are written inside dist/. Emptied before each build, so it must be a folder of its own there |
| cacheDir | 'node_modules/.sitelo/images' | Shared dev/build encode cache |
| remote | false | Download and optimize https:// images at build time |
| prune | true | Delete originals nothing in dist/ references after rewriting; false keeps them |
| dev | true | Set false to serve untouched originals in dev |
| concurrency | CPUs − 1 (max 8); 1 when remote: true | Parallel encodes |
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 can't write them by hand; name the width on the URL instead:
<img src="/images/hero.png?w=400" alt="Sunrise">
<img src="/images/hero.png?w=400&format=jpeg" alt="Sunrise">The tag comes out with that one file — no srcset, no sizes, real
width/height:
<img src="/assets/img/hero.9f8e7d6c-400.webp"
alt="Sunrise"
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/sunset.png?w=300&h=300&position=entropy">
<img src="/images/team.jpg?w=300&h=300&position=attention">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 carry a srcset, sit inside a <picture>, point at an
SVG, an animated GIF, or a remote URL (unless remote: true) are left
exactly as they are. For anything else, mark it:
<img src="/images/exact.png" alt="Pixel art" data-no-optimize>Social-card and favicon images live in <meta> and <link>, which this
never touches — they keep their fixed URL.
Dev server
sitelo dev gives you the real site, not an approximation:
- Pages render on request through Vite's SSR module runner — edit a
page, its
data(), or any imported module and reload. - Dynamic routes render on demand. Visit
/blog/anythingandblog/[slug].ht.jsrenders withparams.slug = 'anything'— no need to list every param ingenerateStaticParamswhile developing. (sitelo buildstill only emits the pages you list there.) - File changes inside your pages directory trigger an automatic full-reload in the browser.
- A small dev toolbar on every page shows the source file, params,
and server-island count — plus a Desktop/Tablet/Mobile viewport preview
(real iframe so CSS media queries apply), Copy debug info, and Docs.
Disable with
devToolbar: falseinsitelo.config.js. Production builds never include it. - Errors show a source-mapped code frame in the terminal pointing at the exact line in your page — the server stays alive while you fix it.
── PAGE RELOAD ERROR ───────────────────── src/index.ht.js:6:20
ReferenceError: title is not defined
> 6 │ head(title('My website')),
│ ^
Fix the error and save again.
Watching for file changes...Server islands
Keep the page static, but render marked regions on the server at request time — fresh comments on a cached blog post, a stock badge on a product page. An island is just another function that returns HTML.
1. Write the island — a fragment module under src/islands/
(plain .js/.ts, not .ht.js — islands are fragments, not pages):
// src/islands/comments.js
export default async function comments({ props, request }) {
const comments = await fetchComments(props.postId)
return `<ul>${comments.map((c) => `<li>${c.text}</li>`).join('')}</ul>`
}2. Place it in a page with island() — the build ships the fallback:
// src/blog/[slug].ht.js
import { island } from 'sitelo/islands'
export default ({ params }) => `
<html>
<body>
<article>…static content…</article>
${island('comments', { postId: params.slug }, '<p>Loading comments…</p>')}
<script type="module" src="/islands.js"></script>
</body>
</html>
`3. Add the client loader (bundled by the normal asset pipeline):
// src/islands.js
import { mountIslands } from 'sitelo/islands/client'
mountIslands()In dev and preview this already works — sitelo / sitelo preview
serve islands at /_sitelo/islands/<name> using the modules in
src/islands/ (preview loads native .js / .mjs; TypeScript islands
work in sitelo via Vite).
In production the static host serves the page; you mount a tiny handler wherever you run server code and it renders the same modules:
// e.g. a Node server, or a serverless/edge function
import {
createIslandsFromDirectory,
createIslandsHandler,
} from 'sitelo/islands/server'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const root = path.dirname(fileURLToPath(import.meta.url))
const handleIslands = createIslandsHandler({
islands: createIslandsFromDirectory(path.join(root, 'src/islands')),
})
// Web Request → Response | null (null = not an island request)
export default { fetch: (request) => handleIslands(request) }Plain Node http/express? Use createIslandsNodeHandler(options) —
same options, (req, res, next) signature. For Netlify / Vercel rewrite
stubs, see examples/islands.
Loading strategies
By default every island fetches as soon as the page loads. A page with
eight islands then makes eight simultaneous requests during first paint.
Pass when to defer the ones that aren't immediately visible:
// Load as soon as the page does — the default.
island('cart', { id }, '<p>…</p>')
// Wait for an idle callback: nice-to-have content that shouldn't
// compete with first paint.
island('recommendations', { id }, '<p>…</p>', { when: 'idle' })
// Wait until it scrolls into view.
island('comments', { postId }, '<p>Loading comments…</p>', {
when: 'visible',
rootMargin: '400px', // start loading 400px early
})| when | Loads |
|--------|-------|
| 'load' (default) | Immediately, in parallel with every other island |
| 'idle' | On requestIdleCallback (falls back to a timeout) |
| 'visible' | When it scrolls into view, via IntersectionObserver |
rootMargin applies to 'visible' only and defaults to 200px. Set the
default for a whole page through mountIslands({ rootMargin }).
Islands also time out rather than spinning forever — 10s by default:
mountIslands({ timeout: 5000 }) // or 0 to disablemountIslands() resolves once the immediate islands have settled;
deferred ones load later on their own and are deliberately not awaited.
A failed or timed-out island keeps its fallback HTML and sets
data-sitelo-island-state="error".
Props are untrusted input
Island props are client-supplied. They are embedded in the page, sent back on the request, and anyone can edit them before doing so:
GET /_sitelo/islands/profile?props={"userId":"someone-else"}Treat the props your island receives exactly like a query parameter —
validate them, and never use them to look up data the viewer isn't
already entitled to see.
When props select privileged data, sign them. Set a secret and sitelo signs each placeholder at build time and rejects anything else with a 403:
SITELO_ISLANDS_SECRET=$(openssl rand -hex 32) sitelo buildThe same variable is read by sitelo, sitelo preview, and
createIslandsHandler, so dev, preview, and production all agree. Give
your production host the same secret. Prefer to set it in code?
import { configureIslands } from 'sitelo/islands'
configureIslands({ secret: process.env.MY_SECRET })Called from a module your pages import, that signs the build and is
checked by sitelo's dev endpoint too. sitelo preview and a production
handler render no pages, so they read SITELO_ISLANDS_SECRET — or pass
createIslandsHandler({ secret }) yourself.
Signatures are HMAC-SHA256 over the island name and its props, so a signature issued for one island can't be replayed against another. Signing proves the props came from your build — it does not hide them. They are still visible in the HTML, so they must still be non-secret.
Without a secret, props are accepted as-is and validating them is entirely your island module's job.
Notes:
- Island modules receive
{ name, props, request }and must return an HTML string. Props are JSON, embedded in the placeholder and sent back on the request — keep them small and non-secret. - Islands are server-only: unreferenced code under
src/is never emitted todist/, so island modules (and their imports) don't ship to the browser. - No island endpoint in production? The fallback HTML simply stays — pages degrade gracefully.
Generated extras
404 page
Create src/404.ht.js and it's emitted as dist/404.html (the
convention GitHub Pages, Netlify, and Cloudflare Pages all understand).
No 404 page? A clean default is generated.
Sitemap
Set your site URL and dist/sitemap.xml is generated from all static
routes, correctly escaped:
// sitelo.config.js
export default {
site: 'https://example.com',
}RSS feed
// sitelo.config.js
export default {
rss: {
site: 'https://example.com',
title: 'My Blog',
description: 'Latest posts',
routePrefix: '/blog', // which routes become feed items
},
}Produces dist/rss.xml with an item for every page under routePrefix.
Pagefind search
Pagefind is an optional peer dependency — install it when you enable search:
npm install -D pagefind # or: pnpm add -D pagefind / yarn add -D pagefindThen sitelo build indexes your site into dist/pagefind/ (and syncs a
copy to public/pagefind/ so the next sitelo / sitelo preview can
serve search without rebuilding):
// sitelo.config.js
export default {
pagefind: true,
}Mark content with data-pagefind-body, add a mount point, and load the UI:
<main data-pagefind-body>…</main>
<div id="search"></div>// src/main.js — after sitelo build has produced /pagefind/
const style = document.createElement('link')
style.rel = 'stylesheet'
style.href = '/pagefind/pagefind-ui.css'
document.head.appendChild(style)
await new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = '/pagefind/pagefind-ui.js'
script.onload = resolve
script.onerror = reject
document.body.appendChild(script)
})
new PagefindUI({ element: '#search', showImages: false })Gitignore public/pagefind/. Full walkthrough:
sitelo.dev/docs/configuration#pagefind-search.
Build report
sitelo build ends with a summary of what it actually produced:
[sitelo] build report
pages 22 files 254.0 kB
js 7 files 479.1 kB
css 4 files 85.0 kB
images 5 files 788.7 kB
other 101 files 702.2 kB
───────────────────────────
total 139 files 2.31 MB
largest
logo.png 753.6 kB
pagefind/pagefind-component-ui.js 175.5 kB
pagefind/pagefind-ui.js 120.0 kB
vite 161ms · images 35ms · pagefind 63ms · total 260msVite's own table lists the modules it bundled; this covers the whole
output directory — files copied from public/, post-build image
variants, and generated extras included — so the totals are what you
actually ship. The phase timings make the post-build work visible:
a slow build is usually image encoding, not rendering.
Turn it off, or show more (or fewer) of the largest files:
// sitelo.config.js
export default {
buildReport: false, // or { top: 10 }
}--logLevel silent suppresses it as well.
Lighthouse audits
Lighthouse is an optional peer dependency — install it only if you want audits:
npm install -D lighthouse # or: pnpm add -D lighthouse / yarn add -D lighthouseThen audit the build you already have:
sitelo build
sitelo lighthousesitelo starts the same preview server sitelo preview uses, points a
headless Chrome at every page in dist/, and prints the scores:
[sitelo] lighthouse mobile - 3 pages
page perf a11y best seo
/ 98 100 100 100
/docs 95 100 100 100
/docs/routing 97 100 100 100
[sitelo] lighthouse audited 3 pages in 31.4sPages are audited at the URL your site actually links — /docs, never
dist/docs.html — so cleanUrls, route groups, mapOutputPath and a
base are all accounted for, and islands answer exactly as they do in
sitelo preview.
The audit server also answers like a static host rather than like a dev
server: a path with no file behind it gets your 404.html with a real
404, not the home page with a 200. That one matters more than it sounds
— Lighthouse asks every site for /robots.txt, and a dev server that
hands back a page of HTML instead costs you an SEO point on every page
for a file that is correctly absent in production.
Thresholds
With no config, the audit is a report. Give it thresholds and it becomes a check:
// sitelo.config.js
export default {
lighthouse: {
exclude: ['404.html'],
thresholds: {
performance: 90,
accessibility: 100,
'best-practices': 95,
seo: 100,
},
},
}Anything under its threshold fails the command with a non-zero exit code, grouped by page:
[sitelo] 2 lighthouse scores below threshold on 1 page
/docs
performance 78 < 90
accessibility 93 < 100Scores are written the way Lighthouse displays them (0–100); its own
0–1 fractions work too. Use mode: 'warn' to log instead of fail.
Options
| Option | Default | Description |
|--------|---------|-------------|
| include | '**/*.html' | Glob(s) or RegExps of pages to audit, relative to the output directory |
| exclude | [] | Glob(s) or RegExps to skip — '404.html' is the usual one |
| sample | — | Audit this many random pages per include pattern instead of all of them |
| categories | all four | Any of performance, accessibility, best-practices, seo |
| thresholds | {} | Minimum score per category |
| mode | 'error' | 'error' fails the run; 'warn' logs and continues |
| formFactor | 'mobile' | 'desktop' applies Lighthouse's desktop preset; ['mobile', 'desktop'] audits both |
| runs | 1 | Runs per page; the reported score is the median |
| output | false | true for .sitelo/lighthouse/, or a directory path |
| formats | ['html'] | Report formats to write: html, json, csv |
| headless | true | false opens a visible Chrome — useful when a page misbehaves |
| chromeFlags | [] | Extra Chrome flags, e.g. ['--no-sandbox'] inside a container |
| port | — | Port for the preview server |
| flags | {} | Passed straight to Lighthouse (see below) |
| config | — | A full Lighthouse config object |
| onBuild | false | Also audit at the end of sitelo build |
Both devices
formFactor also takes an array, and the audit runs once per device:
export default {
lighthouse: {
formFactor: ['mobile', 'desktop'],
thresholds: { performance: 90 },
},
}[sitelo] lighthouse mobile - 3 pages
page perf a11y best seo
…
[sitelo] lighthouse desktop - 3 pages
page perf a11y best seo
…
[sitelo] lighthouse audited 3 pages (mobile, desktop) in 62.3sEach device gets its own table, because the numbers are not comparable across profiles — mobile throttles the CPU four-fold and desktop does not, so the same page routinely scores twenty points apart. Thresholds apply to both, and a failure names the device it came from:
/docs
mobile performance 78 < 90
desktop performance 94 < 90Saved reports take the device into their filename
(index.mobile.report.html) only when both ran, so a one-device setup
keeps the plain name. Two devices is two full audits — budget twice the
browser time.
Sampling a big site
A site grows faster than the audit does: every page is a fresh Chrome
navigation, so 70 pages is minutes, not seconds. sample audits a
handful of them instead, drawn afresh each run.
// sitelo.config.js
export default {
lighthouse: {
include: ['*.html', 'docs/**', 'ui/**'],
sample: 5, // 5 of each — 15 pages, not 70
},
}Per include pattern, not per site, because the two are rarely balanced:
sampling 15 pages out of a site whose 48 component pages outnumber its 13
guides would mostly re-audit components. include is already where the
sections of a site are named, so it is what the sample is drawn along; a
page belongs to the first pattern that matches it, and a section with
fewer pages than the sample is audited whole.
The trade is coverage for time. Sampling can only tell you a section is
healthy, never that every page in it is — a regression on one page
surfaces within a few runs rather than on the next one. Leave sample
off (or set it to null) for the runs that have to be exhaustive, such
as before a release.
Lighthouse's own options
flags is the escape hatch: it is handed to Lighthouse untouched, so
anything its CLI accepts works here in camelCase.
// sitelo.config.js
export default {
lighthouse: {
formFactor: 'desktop',
flags: {
throttlingMethod: 'provided', // --throttling-method=provided
maxWaitForLoad: 60_000, // --max-wait-for-load=60000
blockedUrlPatterns: ['**/analytics.js'], // --blocked-url-patterns=…
extraHeaders: { Cookie: 'preview=1' },
logLevel: 'info',
},
},
}Three flags stay sitelo's: port and output are plumbing, and
onlyCategories follows the categories option so the score table and
the audit can't drift apart. For anything bigger than flags — custom
audits, budgets, a different gatherer set — pass a whole Lighthouse
config object.
Reports
output keeps Lighthouse's full report per page, mirroring the site's
structure:
export default {
lighthouse: {
output: true, // → .sitelo/lighthouse/
formats: ['html', 'json'],
},
}.sitelo/lighthouse/
index.report.html
docs/routing.report.htmlWith runs > 1 the saved report is the median run, so it matches the
score in the table.
In CI, and in the build
sitelo lighthouse is a separate command because an audit costs a few
seconds of real browser time per page. To spend that on every build
anyway:
export default {
lighthouse: {
onBuild: true,
thresholds: { performance: 90 },
},
}It runs last — after image optimization, Pagefind and the link check — so it measures exactly what ships, and its phase time shows up in the build report.
Three things worth knowing before you gate a pipeline on this:
- Lighthouse drives a real Chrome. Install one on the runner (GitHub's
ubuntu-latestalready has it) or pointCHROME_PATHat a binary. Containers usually needchromeFlags: ['--no-sandbox']. - Performance is machine-dependent, badly. A page that scores 100 on
a laptop scores around 60 on a hosted runner — two shared cores, no GPU,
software rendering. That is a systematic offset, not jitter, so
runs: 3will not close it (it only smooths run-to-run wobble on one machine). Gate CI onaccessibility,best-practicesandseo: those are properties of the HTML you shipped and score identically anywhere. Leaveperformanceincategoriesso it still prints, and either read it as a trend or pin it against numbers measured on the runner itself. - Pages are audited one at a time, through a single Chrome. Running them in parallel would compete for the CPU Lighthouse is measuring, so budget a few seconds per page.
Finding unused code
knip finds files, exports and dependencies nothing uses. On a sitelo project it needs one hint: pages and islands are discovered from the filesystem, so nothing imports them, and without being told otherwise knip reports the whole site as unused files.
npm install -D knip// knip.js
import { knipConfig } from 'sitelo