npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sitelo

v2.9.13

Published

Zero-config static site generation powered by Vite and vite-plugin-html-pages

Readme

sitelo

npm version npm downloads license vite

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 build

Get:

dist/
  index.html      ← rendered, with <!DOCTYPE html> added for you
  styles.css      ← bundled + minified, because your page referenced it
  404.html        ← generated automatically

That'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 a srcset, in dev and in the build
  • Real dev server — pages render on request (dynamic routes included, no generateStaticParams needed 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 sitelo

Requires 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 build

Configuration (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.html

Any 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 builds
  • memory — in-process, cleared when the process exits
  • fs — persisted in node_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 prefix

The 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 out

fillableIcons() 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", announced

Names 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))">
  &times;
</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 emits main.js and rewrites the URL in the generated HTML to match (.mjs likewise becomes .js). Code samples inside <pre> and <code> keep the source name.
  • Unreferenced code files are not emitted. A helper like src/lib/api.ts that you only import from data() stays out of dist/ — 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 page

Because 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 sharp

It 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/height so the page doesn't shift while images load, plus loading="lazy" and decoding="async".
  • Works in dev too. sitelo dev rewrites 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 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:

<img src="/images/sunset.png?w=300&h=300&position=entropy">
<img src="/images/team.jpg?w=300&h=300&position=attention">
  • 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 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/anything and blog/[slug].ht.js renders with params.slug = 'anything' — no need to list every param in generateStaticParams while developing. (sitelo build still 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: false in sitelo.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 disable

mountIslands() 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 build

The 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 to dist/, 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 pagefind

Then 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 260ms

Vite'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 lighthouse

Then audit the build you already have:

sitelo build
sitelo lighthouse

sitelo 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.4s

Pages 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 < 100

Scores 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.3s

Each 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 < 90

Saved 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.html

With 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-latest already has it) or point CHROME_PATH at a binary. Containers usually need chromeFlags: ['--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: 3 will not close it (it only smooths run-to-run wobble on one machine). Gate CI on accessibility, best-practices and seo: those are properties of the HTML you shipped and score identically anywhere. Leave performance in categories so 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