sitelo
v2.7.5
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
- 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 <style> element holding the whole stylesheet
(~7 kB gzipped), so there is nothing to copy and nothing to configure.
If you would rather link it once and let the browser cache it across
pages, import the CSS from a bundled entry file instead:
// src/main.js
import 'sitelo/ui/styles.css'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' } },
}),
)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({ 'aria-label': 'Close' }, icon('close'))
text(icon('check', { size: 'sm' }), ' Done')Icons are inline for the same reason styles() 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.
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 popover elements,
so the browser handles opening, the backdrop, click-outside and Escape.
Accordions are <details name>, menus are <details>, tooltips are
CSS.
Four things want a script — tabs whose panels swap in place, the close button on a dismissible alert, closing a menu on an outside click or Escape, and the theme toggle — 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 into the build; each is well under a kilobyte, none is
fetched before the first interaction, and every component renders
correctly until it is — panel tabs show the panel the server marked
active, menus open and close on their own, the dismiss button does
nothing.
Deploying under a sub-path, or hosting the modules elsewhere:
// vite.config.js
sitelo({ uiClient: { base: '/assets/su/' } })toast() is the exception, because nothing on the page triggers it for
you. Import it, along with the theme helpers, from a bundled entry:
// src/main.js
import { toast, setTheme } from 'sitelo/ui/client'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 |
| Feedback | alert empty progress spinner 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 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.
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.js"></script>
</head>
<body>...</body>
</html>
`At build time:
- Referenced JS / TS / CSS is bundled with esbuild — imports are
inlined, output is minified, and
.tsfiles compile to.js. - 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.
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. 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, and the tag is rewritten in place:
<img src="/assets/img/hero.a1b2c3d4-1200.webp"
alt="Sunrise"
sizes="(max-width: 1200px) 100vw, 1200px"
width="1200" height="800"
loading="lazy" decoding="async"
srcset="/assets/img/hero.9f8e7d6c-400.webp 400w,
/assets/img/hero.5b4a3c2d-800.webp 800w,
/assets/img/hero.a1b2c3d4-1200.webp 1200w">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, and never upscales. A 600px
source with
widths: [400, 800, 1200]emits 400 and 600, 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.
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 largest is also the cap |
| 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/ |
| cacheDir | 'node_modules/.sitelo/images' | Shared dev/build encode cache |
| remote | false | Download and optimize https:// images at build time |
| prune | false | Delete originals nothing references after rewriting |
| dev | true | Set false to serve untouched originals in dev |
| concurrency | CPUs − 1 (max 8); 1 when remote: true | Parallel encodes |
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 })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.
Plugin options
Set these in sitelo.config.js (or pass them to htmlPages() in a Vite
config):
// sitelo.config.js
export default {
pagesDir: 'src',
cleanUrls: true,
site: 'https://example.com',
missingAssets: 'error',
debug: false,
}| Option | Default | Description |
|--------|---------|-------------|
| pagesDir | 'src' | Directory containing pages and assets |
| pageExtensions | ['.ht.js', '.html.js', ...] | Which file suffixes are pages |
| include | derived from pagesDir | Custom glob(s) for page discovery |
| exclude | [] | Glob(s) to exclude from discovery |
| root | Vite root | Override the project root |
| cleanUrls | true | /about/index.html (/about) instead of /about.html |
| site | — | Base URL; enables sitemap.xml |
| rss | — | RSS config (site, title, description, routePrefix) |
| pagefind | — | true or options object; indexes after sitelo build (CLI only). Requires the pagefind peer dependency |
| images | — | true or options object; optimizes images in dev and after sitelo build (CLI only). Requires the sharp peer dependency |
| missingAssets | 'error' | 'error' or 'warn' for broken asset references |
| linkCheck | — | true, 'warn', 'error', or { mode, exclude, checkFragments }; checks internal <a href> against the build (CLI only) |
| lighthouse | — | true or options object; audits the build via sitelo lighthouse (CLI only). Requires the lighthouse peer dependency |
| mapOutputPath | — | (page) => string to customize output filenames |
| generatedTypesDir | '.sitelo/types' | Where generated page helper .d.ts files are written |
| displayName | 'sitelo' | Label used in console / overlay messages |
| devToolbar | true | Dev-only bottom toolbar (route, file, params, islands). Set false to hide |
| devToolbarDocsUrl | sitelo docs | Docs link in the toolbar |
| renderConcurrency | 8 | Pages rendered in parallel |
| renderBatchSize | max(concurrency, 32) | Pages per render batch |
| buildReport | true | Post-build summary. false to disable, or { top } for how many large files to list (CLI only) |
| debug | false | Verbose logging of discovery, routing, and emission |
Performance
Large sites can raise the parallelism:
// sitelo.config.js
export default {
renderConcurrency: 16,
renderBatchSize: 128,
}Comparison
| Tool | What it is | |------|------------| | Astro | Component-based SSG with its own compiler and islands | | Next.js | Full React framework with SSR/ISR | | Eleventy | Template-language SSG (Nunjucks, Liquid, ...) | | sitelo | Functions returning HTML, powered by plain Vite |
If you want components, hydration, and a framework — use a framework. If you want HTML files out of JavaScript functions with the Vite dev experience, this is the smallest tool that does the whole job.
Good fits
- Marketing and landing pages
- Blogs and documentation sites
- HTML-first projects with a sprinkle of JS
- API-driven static sites (with
fetchWithCache) - Any site where "view source" should show exactly what you wrote
License
MIT
Docs site
The documentation at sitelo.dev lives in
docs/ and is itself built with sitelo +
javascript-to-html:
npm run docs:dev
npm run docs:build