Scaffold a new Litro app — fullstack SSR, Markdown blog, or docs site. Pick Lit or FAST Element via --adapter; built on web components and the Nitro server.
npm install @beatzball/create-litro pnpm add @beatzball/create-litro
Scaffold a new Litro app.
# npm
npm create @beatzball/litro@latest my-app
# pnpm
pnpm create @beatzball/litro my-app
# yarn
yarn create @beatzball/litro my-app
# bun
bun create @beatzball/litro my-app
# deno
deno init --npm @beatzball/litro my-app
Follow the interactive prompts to choose a recipe, rendering mode, and framework adapter, or pass flags directly to skip them:
# Fullstack SSR app with Lit (default)
npm create @beatzball/litro@latest my-app -- --recipe fullstack --mode ssr
# Starlight docs + blog with FAST Element
npm create @beatzball/litro@latest my-docs -- --recipe starlight --adapter fast
# Starlight docs + blog, static output
npm create @beatzball/litro@latest my-docs -- --recipe starlight
# Landing page + starlight docs, no blog
npm create @beatzball/litro@latest my-site -- --recipe supernova --no-blog
# List all available recipes
npm create @beatzball/litro@latest -- --list-recipes
--adapter flagChoose the web component framework for your project:
| Value | Framework | DOM Model | SSR | Status |
|---|---|---|---|---|
lit (default) |
Lit 3 | Shadow DOM | Declarative Shadow DOM | Supported |
fast |
FAST Element 2 | Shadow DOM | Declarative Shadow DOM | Supported |
elena |
Elena | Light DOM | Direct rendering | Deprecated — removed in v1 |
Omitting --adapter defaults to lit.
elena is deprecated and will be removed at v1. The interactive prompt no
longer offers it, and --adapter elena prints a notice before it scaffolds.
Nothing else changes: the adapter, the recipe templates and the generated app all
still work, so a project already on Elena keeps building. ui() — an agent tool
that returns a server-rendered component — throws on Elena, which is the reason.
New projects should use lit or fast.
Not every recipe supports every adapter. A recipe declares what it can really produce, and asking for anything else fails before a single file is written:
| Recipe | Adapters |
|---|---|
fullstack |
lit, elena (deprecated) |
11ty-blog |
lit |
starlight |
lit, fast, elena (deprecated) |
supernova |
lit |
$ npm create @beatzball/litro@latest my-site -- --recipe supernova --adapter fast
The 'supernova' recipe supports the 'lit' adapter today. Re-run without
--adapter, or pick a recipe that supports 'fast'.
The interactive wizard offers only the adapters the chosen recipe supports, and does not ask at all when there is just one.
A recipe may ask its own questions after the adapter is chosen. The answers are
recorded in the scaffolded app's litro.recipe.json under options.
A flag answers a question without being asked, for scripts and CI:
| Flag | Answers |
|---|---|
--blog / --no-blog |
Include a blog? |
A flag for a question the chosen recipe does not ask is an error, not a silently ignored argument.
fullstack (default)A fullstack Lit + Nitro app with file-based routing and server-side rendering.
Generated app includes:
pages/index.ts — home page with definePageData() server fetchingpages/blog/index.ts — blog listing pagepages/blog/[slug].ts — dynamic post page with generateRoutes() for SSGserver/api/hello.ts — JSON API endpointnitro.config.ts, vite.config.ts, tsconfig.json11ty-blogA Markdown blog using the litro:content content layer, compatible with the 11ty data cascade format (frontmatter, .11tydata.json directory data, _data/metadata.js global data). Supports both SSR (dev server) and SSG (prerender to static HTML).
Generated app includes:
content/blog/ — Markdown posts with YAML frontmattercontent/_data/metadata.js — global site metadatapages/index.ts — home page showing recent postspages/blog/index.ts — post listing with all postspages/blog/[slug].ts — individual post page with generateRoutes()pages/tags/[tag].ts — tag-filtered post listingserver/api/posts.ts — JSON API for posts (optional ?tag= filter)litro.recipe.json — written to the project root so the content plugin knows where to find postsstarlightAn Astro Starlight-inspired docs + blog site. SSG-only (no --mode flag needed).
Generated app includes:
content/docs/*.md — documentation pages with sidebar ordering frontmattercontent/blog/*.md — blog posts with title, date, tags, description<starlight-page>, <starlight-header>, <starlight-sidebar>, <starlight-toc><litro-card>, <litro-card-grid>, <litro-badge>, <litro-aside>, <litro-tabs><sl-*> names reserved for Shoelace; Litro's own primitives use litro-*)server/starlight.config.js — site title, nav links, sidebar groupspublic/styles/starlight.css — full --sl-* CSS token layer with dark/light modehighlight.js (fire theme, applied at SSG build time)supernovaThe starlight recipe with a product landing page in front of it. SSG-only (no --mode flag needed). It sets extends: 'starlight', so the starlight template is copied in first and this recipe's files are written on top.
It asks one question, Include a blog?, answered by --blog / --no-blog. Declining removes content/blog/, pages/blog/, the Blog nav entry, the Blog button on the landing page and the Blog card in its card grid.
Generated app includes everything the starlight recipe generates, plus:
pages/index.ts — the landing page, which replaces starlight's splash page at /<litro-hero-nova> — the hero backdrop, a star going off over a star field, drawn entirely in CSS (no image file)<litro-install-command> — one command with a $ prompt and a copy button<litro-feature-row> — a "what it does" row with optional command chips and an optional picture<litro-steps> — a numbered list with a connector line<litro-key-hints> — key and meaning pairs as a definition list<litro-status-line>, <litro-state-badge> — the status line fixed to the foot of the window, stating facts about the project or the page<litro-term-window> — a small terminal picture: state rows, or a slotted shell transcript<litro-hero-video> — a product recording with a poster and a play/pause button; the recipe ships no clip, so the section is commented out in pages/index.ts--brand-* token block in the landing page, so the whole page retints from one edit; the docs half keeps its own --sl-* tokenscd my-app
pnpm install
pnpm dev # start dev server on http://localhost:3000
pnpm build # production build
pnpm preview # preview the production build
For static site generation (SSG):
LITRO_MODE=static pnpm build # prerenders all routes to HTML → dist/static/
litro:content virtual moduleThe 11ty-blog recipe uses litro:content, a virtual module provided by the Litro framework:
import { getPosts, getPost, getTags, getGlobalData } from 'litro:content';
// In a page file:
const posts = await getPosts({ tag: 'tutorial', limit: 5 });
const post = await getPost('hello-world');
const tags = await getTags();
const meta = await getGlobalData(); // reads _data/metadata.js
The content layer reads Markdown files from the directory specified in litro.recipe.json (contentDir). Posts are sorted by date descending. Draft posts (frontmatter draft: true) are excluded by default.
Add /// <reference types="litro/content/env" /> (or the equivalent tsconfig.json entry) for TypeScript type support.
Apache License 2.0 — Copyright 2026 beatzball.
Changelog
dist and the changelog, without compiled tests. The recipe templates the scaffolder reads at runtime stay in dist/recipes. The tarball no longer carries the duplicate recipes sources, tests or build config: 479 files and 1.83 MB unpacked become 224 files and 0.76 MB.b8892c3: The scaffolded server/routes/[...].ts route matcher now escapes the literal
runs between params before it builds a RegExp. Unescaped, a route path such as
/@[handle]/:statusId turned [handle] into a character class, so six
one-letter handles answered 200 and the real path answered 404 (issue 234).
4569da2: Scaffold every Lit recipe with a two-statement bootstrap entry, and unpin
vite from ~8.0.16 to ^8.1.6.
A new app's app.ts is now exactly:
import "@lit-labs/ssr-client/lit-element-hydrate-support.js";
void import("./app-main.js");
Everything the entry used to import moved to a new app-main.ts, unchanged.
This replaces the ~8.0.16 pin added in the previous release, which held Vite
8.1+ back rather than fixing anything.
Importing the hydrate support first was never enough. All of a module's static
imports evaluate before its own body, so what decides the order is which chunk
the bundler put lit-element in — and from Vite 8.1.6 (Rolldown 1.1.5) onward
it lands in a chunk the entry imports statically whenever the entry and a page
component both reach the same app-level barrel, which is what an ordinary
src/lib/index.ts with an html or css helper in it is. Hydration then never
installed, with a green build, correct prerendered HTML and no console error. A
dynamic import is the one thing that cannot be hoisted ahead of the entry body.
Two details of the split, handled in the templates: app-main.ts can evaluate
after DOMContentLoaded has already fired, so the recipes that waited on that
event check document.readyState first rather than adding a listener that would
never run; and app.ts must stay two statements, because one ordinary import
added to it reintroduces the fault.
The FAST and Elena templates are unchanged — their adapters never write a Lit element's shadow DOM on the server, so there is nothing for a missing patch to discard.
6db1bbb: Pin vite to ~8.0.16 in every recipe template, so a new app cannot install Vite 8.1 or later.
From Vite 8.1 (Rolldown 1.1.5) onward, Rolldown can refuse to fold lit-element's chunk into the entry chunk even though the entry statically reaches it. The entry then statically imports that chunk, so it evaluates before the entry's own body — which is where @lit-labs/ssr-client/lit-element-hydrate-support.js installs globalThis.litElementHydrateSupport. LitElement reads that hook exactly once, at module evaluation, finds nothing, and no element is ever patched. Hydration silently never runs: the build exits 0, nothing is logged, and the server-rendered DOM is thrown away and re-rendered on the client.
The templates declared ^8.0.16, which installs 8.3.4 today, so every newly scaffolded app was exposed from the first build. ~8.0.16 keeps patch updates inside 8.0.x and holds 8.1 back.
This is a stopgap, not the fix. The real fix is a two-statement bootstrap entry — import the hydrate support, then void import('./app-main.js') — so the entry body cannot be preceded by any static import. Tracked in issue 236, along with a runtime assertion in LitroOutlet so that upgrading repairs apps already generated, and a build-time bundle check, since no test suite catches this.
74d3e45: Deprecate the elena adapter. The interactive prompt now offers lit and
fast only, and --adapter elena prints a one-line notice on stdout saying the
adapter is deprecated and will be removed at v1.
Nothing is removed. --adapter elena still resolves, still scaffolds, and still
produces a working Elena app, and both recipe overlays that declare it are
unchanged. ui() — an agent tool that returns a server-rendered component —
throws on Elena, so the adapter cannot reach Litro's most distinctive feature;
that is the reason for the deprecation. New projects should use lit or fast.
The adapter is removed at v1.
1f347da: Fix two defects in every recipe's catch-all page handler.
A trailing slash matched no route. The generated handler built a RegExp
ending in $ with no optional slash, so /docs/getting-started/ fell through
to the 404 branch. The client router was equally strict, which is what made
this hard to see: on a static host the page still arrived 200 with every
asset, because an SSG build writes docs/getting-started/index.html. The
component was never defined and the recipe's :not(:defined) rule hid a
document that was fully present in the DOM — a blank page with no console
error. The handler now canonicalizes with normalizePathname from
@beatzball/litro, the same helper the client router uses, so both halves
agree.
A miss was served as 200 OK. The not-found branch set a body and a
content-type but never a status, so a 404 page went out as a success. Crawlers
index that, and a prerender counts it as a real page. The handler now calls
setResponseStatus(event, 404).
Scaffolding is a copy, so an existing site does not get this by upgrading.
Patch your own server/routes/[...].ts:
import {
defineEventHandler,
setResponseHeader,
setResponseStatus,
getRequestURL,
} from "h3";
import { normalizePathname } from "@beatzball/litro";
export default defineEventHandler(async (event) => {
const pathname = normalizePathname(getRequestURL(event).pathname);
const result = matchRoute(pathname);
if (!result) {
setResponseStatus(event, 404);
// ...
}
// ...
});
a631454: supernova: add a "one thing, proved" band to the landing page
A new section between the capability grid and the "built on" panes: one claim, a small code example in a band with a title strip, and the two things that code produces — the value a program gets back and the thing a person sees.
The code and the returned data are syntax highlighted on the server, through
the template's own src/highlight.ts, so the section reads the same with
JavaScript turned off and highlight.js stays out of the client bundle. The
recipe gains no dependency: highlight.js and that file already ship with the
starlight template this recipe extends.
Everything in the band is placeholder and the example really runs. The band has no accent stripe, no eyebrow and nothing that moves; the cells share hairlines under one outer rule, the way the capability panes already do.
8b5172c: Stop every scaffolded site scrolling sideways on a phone.
The box model itself is fixed in @beatzball/litro, on LitroPage. What
changes here are the pages and components that carry their own styles and so
have to compose the reset, plus three narrower overflows found by measuring:
a long site name no longer pushes the header's theme toggle off a 320px
screen, a Markdown table with one long unbreakable token in a cell no longer
takes the page sideways, and an inline code span longer than the screen now
breaks while a code block keeps its own horizontal scroll.
a398c22: Refuse an adapter the chosen recipe cannot produce.
--recipe supernova --adapter fast used to exit 0 with a mixed app. Supernova
extends starlight, so starlight's FAST overlay set LITRO_ADAPTER = 'fast' and
swapped the docs pages over, and supernova's own template then put its Lit
landing page and its nine Lit components back on top. Nothing said so.
Each recipe now declares the adapters it really supports in its config —
fullstack lit and elena, 11ty-blog lit, starlight lit, fast and elena,
supernova lit. Support is declared, never read off the template-<adapter>/
directories on disk, because a recipe that extends another inherits its base's
overlays. Asking for anything else exits non-zero, names the recipe, the
adapter and what the recipe does support, and writes nothing at all. The
interactive prompt offers only the adapters the chosen recipe supports, and
skips the question when there is one.
bada4f1: Give a scaffolded docs site a page at /docs.
The starlight recipe shipped only pages/docs/[slug].ts, so /docs was a 404.
A reader who typed the obvious path, or trimmed a URL back one segment from
/docs/getting-started, landed on nothing — and a static host that will not
list a directory turns that into a 403, which reads as "forbidden" rather than
"no such page". Supernova inherited the same hole.
Every adapter variant of the recipe now ships pages/docs/index.ts, a landing
page that lists each sidebar group and each entry under it, with the entry's
frontmatter description where the content file has one. It is a real
prerendered page, not a redirect, so any static host serves it and it works
with no client JavaScript. The site navigation's Docs entry points at /docs,
which is also what lets the prerender crawler reach the page — in the recipe
template and in the config --for-repo writes for itself, which is a separate
copy.
A sidebar group whose items array is empty is dropped rather than rendered as
a heading over an empty list.
c69b421: Scaffold an absolute project path where it was asked for.
create-litro /tmp/demo/my-app wrote a whole ./tmp/demo/my-app tree inside
the current directory and printed the usual success block. The target was built
with join(process.cwd(), projectName), and join concatenates an absolute
second argument rather than replacing the first.
The path is now resolved once and used everywhere: for the target directory,
for the "directory already exists" refusal, and for the path printed back, so
cd <path> is a command that works. A relative path behaves exactly as before.
{{projectName}} — which becomes package.json's name and the site title —
is now the last segment of the path alone, because neither can hold a path.
A quoted leading ~ expands to the home directory instead of creating a
directory literally called ~. A ~someone form is refused rather than
guessed at, as is a path with no last segment to name the project after.
--for-repo now refuses a site outside the repository it names. The path from
the repo root down to the site is published — it becomes the starlight config's
editUrlBase, which is an "Edit this page" link on GitHub, and it heads the
generated AGENTS.md. A site beside the repository produced .. in both, and
an edit link with .. in it does not resolve. The refusal names both paths and
runs before anything is written.
8aafdf0: starlight: docs and blog pages no longer open in light mode on a dark system
The head script set data-theme before the first paint, from the reader's
stored choice or, when there was none, from prefers-color-scheme. That part
was right. starlight-header then resolved the theme a SECOND time in its
first update and wrote the answer back — with a bare ?? 'light' fallback and
no look at the system. It answered a moment later and overwrote a correct value,
so on a dark system every page carrying the header flipped to light right after
it loaded. All three adapter overlays had it.
The header now READS data-theme instead of deciding it, and stops when it is
taken off the page. The head script stays the one decider: it guards every
localStorage read, because storage throws rather than returning null when site
data is blocked, and it follows a later system change while — and only while —
the reader has stored no choice of their own.
The stylesheet gains an @media (prefers-color-scheme: dark) branch guarded by
:not([data-theme="light"]), so a dark system gets a dark page with JavaScript
turned off, and an explicit light choice still wins when scripts are running.
Both themes now declare a color-scheme, so form controls and scrollbars follow
too.
litro-hero-nova also gains two optional tokens, --nova-mark-size and
--nova-mark-size-narrow, for how wide the mark is drawn. A site whose mark
should hold the whole right of the pane sets them in the same block it sets its
colors in; the recipe's defaults are unchanged.
The guard around that read is a browser check, not a document check.
typeof document === 'undefined' is not the same question:
@microsoft/fast-ssr runs connectedCallback on the server against a document
shim that defines document and leaves documentElement undefined, so the
first version of this fix reached
document.documentElement.getAttribute('data-theme') during SSR and threw.
Every page in a FAST starlight site served a stream that stopped at the header.
All three overlays now guard on document.documentElement itself and keep
matchMedia off the server path.
8aafdf0: supernova: the hero gets a default mark, and your own logo still replaces it
8aafdf0: supernova: a redesigned hero, and the fixes the landing page needed once a real site used it
Found while rebuilding litro's own docs home pages on this recipe — the first project to use it in anger.
The hero is redesigned. It drew a shockwave ring, a hot inner glow and a
bright core around the same point as the mark slot, and laid a centered stack
of copy over the top. The logo sat in the middle of a target, and nothing in the
composition led the eye anywhere.
litro-hero-nova is now one screen-high pane: a deep tinted ground, one wide
soft wash of accent light, and the mark slot drawn very large and cropped
against the pane's right edge, so a project's logo reads as atmosphere rather
than as a badge floating in the middle. With no mark slotted nothing is painted
at all — an empty hero is a finished hero, with no ghost shape. Its slots and
its tag are unchanged, so no page has to change; it reads one new optional
token, --nova-hero-min, for the pane's height. Nothing animates, so its
prefers-reduced-motion rule and its keyframes are deleted rather than left as
dead CSS.
The landing page is left-aligned on a column, and its headline is set in the same mono face as the status bar, the badges and the command — so the page speaks in one voice. The sans is kept for prose.
litro-install-command is a slab, not a chip. It fills the column it is
given, the copy control is a labeled panel of its own flush to the right edge,
and a new optional note slot carries one line of small print underneath. Slot
nothing and the line is not there.
litro-status-bar styled its navigation with ::slotted(a), so a page that
handed the bar anything else — a routing link element, or a button that opens
a search dialog — got an unstyled control beside the styled links. The rule
now matches on the slot name, ::slotted([slot='nav']), and resets a
button's own font, background and border.
litro-status-bar let the name segment shrink to nothing on a phone, which
left an empty arrow where the project's name should be. Below 30rem the name
segment is dropped and the mark carries the home link on its own.
The landing page's shadow root never repeated the global box-sizing reset,
so every full-width section was its width PLUS its gutters and a phone
scrolled sideways by exactly one gutter.
litro-status-bar sized and colored its navigation with ::slotted(a), so a
page that handed the bar anything else — a routing link element, or a button
that opens a search dialog — got an unstyled control beside the styled links.
The rule now matches on the slot name, ::slotted([slot='nav']), and resets
a button's own font, background and border.
litro-status-bar let the name segment shrink to nothing on a phone, which
left an empty arrow where the project's name should be. Below 30rem the name
segment is dropped and the mark carries the home link on its own.
litro-install-command had no box-sizing on its box and no min-width: 0
on the command, so a long command pushed the box wider than the space it was
given and a phone scrolled sideways. The command now scrolls inside the slab,
which is what its overflow-x was always meant to do.
A scrolling box a keyboard can reach. Both boxes that scroll sideways on a
narrow screen — the install command and the terminal window — are tab stops
now, with a name to hear on the way in and a focus-visible ring. A region that
scrolls and cannot be focused is unreachable without a pointer; axe-core reports
it as scrollable-region-focusable, and it shipped on every page a scaffolded
site put the install command on. litro-install-command takes a new optional
commandLabel for that name, which defaults to "Install command".
litro-term-window fits on a phone. Its host had no min-width: 0, so as a
grid item its smallest size was the widest transcript line plus its padding. A
wide transcript pushed its column past the screen and the landing page scrolled
sideways at 320px, the width WCAG 1.4.10 measures reflow at. The window shrinks
now and the transcript scrolls inside it. The recipe's own "Get running" grid
asks for minmax(0, 1fr) rather than a bare 1fr for the same reason.
8aafdf0: supernova: one header on every page, and the terminal line moves to the foot
The landing page carried a terminal bar of its own at the top, so a reader met
two different headers on one site — the risk section 13 of the recipe's spec
named. It now carries the SAME starlight-header the docs pages do, and the
terminal character moves to where a status line belongs: a fixed line at the
foot of the window.
litro-status-bar is replaced by litro-status-line, a new tag. It is not a
second layout mode on the old component: every property the bar had was about
being a header — a home link, a tab row, a navigation slot — and none of them
survive the move. What the line does instead is state facts:
Every cell must be true. The bar's tab row showed tasks settling from working to done on a page that had no tasks, which is a drawing in the most credible place on a page. The line has no tabs and no states it cannot know, and with no cells at all it renders nothing rather than an empty bar.
starlight-header gains --sl-font-brand, the face its wordmark and links are
set in. It falls back to the body sans, so a site that never sets it looks
exactly as it did; supernova sets it to the mono, which is the whole of the
difference between its header and the docs pages' one. The header's navigation
also scrolls rather than overflowing below 48rem — on the docs pages it was
hidden behind the hamburger and this never showed, but a landing page has no
sidebar and keeps its links.
8aafdf0: supernova: the cards stop floating, and the landing page gains six sections
The feature block is panes, not cards. litro-pane and litro-pane-grid
replace the card kit on the landing page: square corners, no shadow, and a
hairline that two neighbors SHARE rather than each carrying their own. The grid
is six columns, so a row is two halves or three thirds and seven items fill
three rows with no orphan. Both of the sites this page is measured against do
exactly this, and neither uses a card kit.
The emoji are gone. A pane carries a state glyph, which says something true —
[+] ships, [~] is still moving — and an icon slot for a REAL mark where a
project has one. A scaffolded site has no mark that means anything yet, so it
says it in words and no placeholder icon is drawn.
Six more sections, each one block with a comment above it saying what it is for and that deleting it is fine: a logo wall, a capability block, what the project is built on, a stats row, an ecosystem list, deploy targets, and a real footer with link columns in place of the one-line credit.
Placeholders are honestly empty. The logo wall's slots read "Your logo", the stats are dashes, and nothing invents a company, a download count or a quote. A scaffolded site never claims proof it does not have.
litro-site-footer is new. Pass it no columns and only the fine print is
drawn, which is where this started.
removeBlog now also unpicks a /blog link from the landing page's footer
columns. Nitro's prerenderer crawls the links it finds, so one left behind made
--no-blog prerender a blog whose pages had just been deleted.
8aafdf0: supernova: the landing page follows the theme, and a status cell's words no longer touch
Light mode did nothing on the landing page. The page hard-coded a dark
palette and color-scheme: dark, so the toggle had nothing to act on: a reader
switching to light watched the docs pages change and the landing page stay
dark. The palette now lives in public/styles/starlight.css as --brand-*
values, in the same light and dark blocks the --sl-* tokens use — so the
landing page follows the toggle, the system preference, and the
prefers-color-scheme block that carries both with JavaScript turned off. The
page's token block is unchanged in shape: every line is still
var(--brand-…, <fallback>).
The accent is one value again. --brand-accent reads --sl-color-accent
rather than repeating a hex, so the landing page and the docs pages cannot
drift. Two more accents are derived from it rather than typed: one dark enough
to carry white text on it, for the status line's mode segment and the primary
button, and one dark enough to read as small text on a light page.
A linked status cell lost its word spacing. The gap that separates a glyph
from a word lives on .cell, and a cell that links somewhere has one child —
an anchor — so everything inside it had no gap and the line read
"built withlitro". The anchor is the flex row now.
The hero's mark also reads --nova-mark-opacity, because the value that is
right on a near-black ground is nearly invisible on an off-white one.
8aafdf0: supernova: the showcase takes pictures, and a capability block opens with a claim
The recipe's ecosystem section becomes a showcase: each row is a starting point and the picture slots show the shape it gives you. They ship EMPTY, as dashed frames that say "Your screenshot", exactly like the logo wall — a project fills them with real sites or deletes the list.
A row never claims which recipe a particular site runs. That distinction is the point: a site's look and a site's recipe are different facts, and writing the section this way means it stays true when either one changes.
Links inside a paragraph are underlined now, not only colored. A link told apart by color alone fails for a reader who cannot see the color, and axe-core reports it.
supernova section
covering what it generates and the components it ships, the --blog /
--no-blog flags in the usage examples, and a note that the recipe ships a Lit
template only.251387f: Add <litro-hero-video> to the supernova recipe: a poster, a play/pause
button and a caption slot for a short product recording.
Nothing is fetched by the markup itself — there is no autoplay attribute and
preload is none — so a reader who asked for less motion, or who has
JavaScript off, downloads no video and gets the poster. When motion is welcome
the script starts the clip, which does fetch it; that is the behavior the page
wants, and it is the only path that costs a download. It starts once: the copy
of the element the router replaces on a first load no longer asks for the clip
as well.
The button names the action it performs rather than the state the video is in,
and it follows the video — including when the clip cannot be played at all.
When every <source> fails, Chromium leaves play() pending for good and
paused false, so the button reads the element's own signals instead of the
promise and goes back to "Play".
With no sources there is a poster and no button, which is the state a freshly
scaffolded site is really in: no media ships with the recipe. The recipe's
landing page carries the section commented out, with the import line and the
.sources property the markup needs.
7385d44: The supernova landing page gets its terminal parts: litro-state-badge, litro-status-bar and litro-term-window, scaffolded into the new site's src/components/ like the five before them.
The status bar replaces the docs header on the landing page. It shows the project's mark and name as one link home, a row of tabs each carrying a state badge, and the site's navigation — taken from server/starlight.config.js, the same place the docs header reads, so both halves of the site name the site the same way and link to the same pages. Declining the blog therefore takes the Blog link out of the bar too, because the entry is gone from the navigation.
A badge draws one of five states as a glyph and a color, and the glyph set is a property, so a project can use its own text. It can settle from one state to another; that animation is CSS, so it runs with JavaScript turned off, and prefers-reduced-motion shows the settled state from the first frame instead. The terminal window draws either rows of state, age and name or a transcript you slot in, and it renders as one role="img" with the sentence you give it, so a screen reader hears one description rather than a column of glyphs.
All three render on the server and bring no dependency, no image and no font. The landing page now puts a terminal window in two of its feature rows, and carries a commented-out placeholder where the video section arrives next.
5f51638: Two fixes to the supernova recipe's litro-status-bar.
The tab row is a div of spans rather than an ol of li elements. It still carries one role="img" and the same sentence, and every tab inside it is still hidden from assistive tech, so nothing is announced differently — but ARIA in HTML does not allow role="img" on a list, and axe-core reported aria-allowed-role on the prerendered page. A row of fake tabs is a picture, not a list of anything a reader can act on.
The site name is also shortened with an ellipsis when it does not fit. The bar is one line high, so a long name has always been cut on a narrow screen; it now reads as a cut instead of ending mid-letter.
a829c55: A recipe can now build on another recipe, and can ask its own questions.
extends on a recipe config copies a base recipe's templates in first:
base template/, base template-<adapter>/, then the recipe's own
template/ and template-<adapter>/. Later layers overwrite earlier ones.
One level only — a deeper chain, an unknown base, or a recipe that extends
itself is refused with a message naming both recipes.litro.recipe.json now names the recipe the user chose rather
than the template the file came from, and records the resolved recipe options
under options.--for-repo without
--with-blog behaves exactly as before; a recipe that offers a blog option
gets the same removal, minus the repository link there is nothing to point at.--blog and --no-blog answer a recipe's blog question without a prompt,
for scripts and CI. A flag for a question the chosen recipe does not ask is
an error rather than a silently ignored argument.--for-repo now accepts any recipe that is, or extends, starlight.9e1be1e: supernova: the landing page is now built from five components of its own, and themed from one token block.
litro-install-command, litro-feature-row, litro-steps, litro-key-hints and litro-hero-nova are scaffolded into the new site's src/components/, where the user owns and edits them. They bring no dependency, no image, no font and no video, and they all render on the server, so the page still reads with JavaScript turned off. The copy button is the one part that needs it: when a browser refuses the clipboard it selects the command instead and says "Selected" rather than "Copied", and announces which of the two happened.
The hero art is drawn in CSS — an exploding star over a star field, with a mark slot for the project's own logo and a pulse that stops under prefers-reduced-motion.
Every color now comes from one token block on the landing page's host, grouped into surfaces, text, accent, states and layout. The components define no colors of their own, so editing that block rethemes the whole page. A comment there lists the --sl-* tokens the docs half owns, so neither set is redefined by accident.
06c5bc7: Add the supernova recipe: the starlight docs site with a product landing page in front of it.
supernova extends starlight, so its own template holds one file — the landing page. The docs half, the blog and the shared components are copied in from starlight and are never committed twice. Scaffold it with --recipe supernova, and answer its blog question with --blog or --no-blog.
The landing page is built only from components the starlight template already ships (starlight-header, litro-card-grid, litro-card, litro-footer). Every string on it is a placeholder a user replaces, and the whole page renders on the server, so it reads with JavaScript off.
Declining the blog now also drops the Blog entry from server/starlight.config.js. The header renders that navigation on every page, so leaving the entry behind put a dead link across the whole site, not only on the landing page.
eb70043: Add a "Created using Litro" credit line to every scaffolded site.
A new <litro-footer> component ships in all three recipes and all three
adapters — Lit, FAST and Elena — and names the recipe the project came from:
"Created using Litro, starlight recipe", linking to litro.dev. It is a quiet
line at the bottom of the page and a comment above it says how to remove it.
The starlight recipe places it in the shared starlight-page layout, so docs,
blog and tag pages all get it from one place, plus the splash page which does
not use that layout. The fullstack and 11ty-blog recipes have no shared layout,
so each page places it directly.
Scaffolding now interpolates {{recipe}}, which is what lets one component
file name the recipe it was scaffolded into.
FAST needs a property binding rather than a plain attribute at the usage site:
fast-ssr does not map attributes onto properties, so recipe="starlight"
server-renders the credit without the recipe name. Measured, not assumed.
scripts/verify-scaffolded-apps.mjs now asserts the credit survives to
rendered output for all six variants, reading the prerendered HTML where the
recipe prerenders and the server bundle where it does not. That check is what
caught the FAST problem, which compiled and built cleanly.
7ddd33b: Add --for-repo, which turns a scaffolded starlight site into a specific
project's documentation site.
npm create @beatzball/litro@latest -- site \
--recipe starlight --for-repo . --site-url https://example.dev
It reads the repository's name, description, remote and default branch, then
writes _data/metadata.js, server/starlight.config.js (title, nav with a
GitHub link, and "Edit this page" links pointing at the right branch and
subdirectory), a Dockerfile + nginx.conf deploy, and an AGENTS.md that
tells any coding agent how to add a page correctly.
The sample blog is removed by default (--with-blog keeps it) — including the
landing page's link to it and the blog routes in the generated e2e spec, so a
new site has no dead link and its own test suite passes.
It does not write your documentation. Turning a README into good pages is a
judgement call, so it leaves one honest placeholder page and points the agent
at AGENTS.md.
Every lookup degrades rather than failing: with no git remote it falls back to
the directory name, sets editUrlBase: null, omits the GitHub nav entry, and
prints what you still need to fill in. Works offline and without gh.
Other flags: --site-url <url>, --deploy <docker|none>, --with-blog.
packageManager is now pinned in the scaffolded package.json to the pnpm
that ran the scaffold, so the Docker build uses the same pnpm that produced
the lockfile instead of whatever corepack resolves as newest.
cec1777: Scaffolded apps now actually get a .gitignore.
npm strips .gitignore from every published tarball — it is on npm's own
exclusion list with no way to opt out via files. The recipe templates stored
the file under its final name, so it shipped fine from a local build and
silently vanished for anyone installing from the registry. Every app created
with npm create @beatzball/litro arrived with no ignore rules at all, so
its first git add swept in node_modules/, dist/, the generated stubs, and
any .env.
The templates now store it as gitignore and the scaffolder renames it on
copy. .gitkeep and .11tydata.json are unaffected — npm's exclusion list is
specific, not "all dotfiles".
The scaffolded-apps CI guard now scaffolds from the packed tarball rather
than the local build, and asserts the resulting app has a .gitignore covering
node_modules/, dist/ and server/stubs/. Running the local build was why
this class of bug was invisible: a tarball is not the source directory with a
different name.
83c8d5a: Scaffolded apps no longer list 'source' in resolve.conditions. An installed
package's TypeScript is never transpiled by Vite, so resolving to source emitted
raw decorators and produced a client bundle no browser could parse — a blank
page from a build that reported success. Templates now consume the packages'
compiled output.
Affected the fullstack, 11ty-blog, and starlight recipes on the lit and
fast adapters, on Vite 8. The elena adapter was unaffected.
7c93db8: Two fixes to what a scaffolded app ships:
Generated stubs are no longer committable. The templates ignored
server/stubs/page-manifest.ts (and the fullstack action stubs) by name, but
the scanners also emit litro-content.js and agent-*.ts, and several of
those embed the absolute filesystem path of the machine that built them. A new
app would commit a local path on its first git add. The whole
server/stubs/ directory is now ignored, so a newly added scanner cannot
silently start leaking one. server/plugins/litro-actions.ts is unaffected —
it lives outside stubs/ and stays tracked, as intended.
End-to-end tests no longer run against whatever else owns port 3000. The
generated playwright.config.ts hardcoded port 3000 with
reuseExistingServer. If any unrelated app was already listening there,
litro dev moved to the next free port while Playwright ran the entire suite
against the stranger and reported confusing 404s. The config now uses port
4321 (override with LITRO_E2E_PORT), passes --port to litro dev, and
never reuses an existing server.
nitropack from ^2.13.1 to ^2.13.4 in scaffolded recipe templates, resolving Medium-severity advisories GHSA-5w89-w975-hf9q and GHSA-9phm-9p8f-hw5m.server/middleware/vite-dev.ts now builds its dev Vite server from litroViteDevConfig() (and pre-warms the client entry via warmupLitroViteServer()), so scaffolded apps get the live-source dev entry fix for issue 97 instead of serving a stale pre-built bundle.nitro.config.ts, no-store route rule, #litro/action-manifest import mapping, litroActionsPlugin() in vite.config.ts, form enhancer in app.ts, and a demo greet action with a progressive-enhancement form on the home page.vite to ^8. New projects require Node ^20.19.0 || >=22.12.0.localhost:3030 while the default Litro dev server listens on 3000.deno init --npm @beatzball/litro my-app — the older deno create npm:… form was removed in Deno 2.x).--adapter flag for selecting framework adapter (lit/fast)--adapter elena option with fullstack recipe template for Elena (light DOM web components).skipLinks array API with DEFAULT_SKIP_LINKS619ec3c: feat(seo): inject seoHead and seoTitle from pageData into HTML shell
Pages can now return seoHead (a string of meta/JSON-LD tags) and seoTitle from definePageData(). The framework extracts these at request time and injects them into the actual <head> element of the HTML response — not buried in the __litro_data__ JSON blob.
Also bumps the h3 dependency to >=1.15.6 to patch CVE GHSA-22cc-p3c6-wpvm.
Also improves npm discoverability: updated descriptions and keywords for all three packages, and rewrote the framework README with a Hello World example, comparison table, and SEO section.
highlight.js with the fire palette theme (dark background, orange keywords, sky-blue strings, amber numbers). The DocPage component includes static override styles with all .hljs-* token rules so highlighting works correctly inside the Lit shadow DOM.2456382: Add official documentation site and starlight recipe improvements
@beatzball/litro
LITRO_BASE_PATH env var support in create-page-handler.ts — prefixes the /_litro/app.js script URL for sub-path deployments (e.g. GitHub Pages project sites at owner.github.io/repo/)LitroPage.connectedCallback() now peeks at the __litro_data__ script tag to set serverData before Lit's first render, without consuming the tagLitroOutlet.firstUpdated() no longer eagerly clears SSR children — the router's atomic swap handles it@beatzball/litro-router
_resolve(): new element is appended hidden alongside old SSR content, waits for updateComplete + requestAnimationFrame, then old content is removed and new element revealed — eliminates blank flash and layout shift during navigation_lastPathname guard prevents re-render on hash-only popstate events (TOC / fragment link clicks)@beatzball/create-litro
sl-card, sl-card-grid, sl-badge, sl-tabs, sl-tab-item, sl-aside → litro-card, litro-card-grid, litro-badge, litro-tabs, litro-tab-item, litro-aside to avoid collision with Shoelace's registered custom element names@shoelace-style/shoelace) — tree-shaken component imports in app.ts, icon assets at /shoelace/assets/, theme CSS at /shoelace/themes/; sl-button and sl-icon-button now available in all scaffolded starlight siteslitro-card improvements — equal-height cards via flex column, icon + title rendered inline side-by-side, new iconSrc prop for image-based icons:host { position: sticky } (works correctly across shadow DOM boundary); sticky TOC matching sidebar behaviourprefers-color-scheme when no localStorage preference is setdocs/ workspace (@beatzball/litro-docs) — official Litro documentation site built on the starlight recipe, deployed to GitHub Pages via .github/workflows/docs.ymlplaywright.config.ts and e2e/index.spec.ts with 3 starter tests, and @playwright/test in devDependencies.78fdaf6: Add starlight recipe — Astro Starlight-inspired docs + blog site scaffolded as Lit web components with full SSG support.
npm create @beatzball/litro my-docs -- --recipe starlight scaffolds a static docs + blog site with:
<starlight-page>, <starlight-header>, <starlight-sidebar>, <starlight-toc><sl-card>, <sl-card-grid>, <sl-badge>, <sl-aside>, <sl-tabs>, <sl-tab-item>/ (splash), /docs/:slug, /blog, /blog/:slug, /blog/tags/:tag — all SSG-prerendered--sl-* CSS token layer with dark/light mode toggle and no flash of unstyled contentserver/starlight.config.js — site title, nav links, sidebar groups--mode flag needed)76d3bc7: fix: client-side navigation links do not work on first load
<litro-link> clicks were silently no-ops in scaffolded apps because of
three compounding bugs.
Bug 1 — Empty route table on init (LitroOutlet, app.ts)
app.ts set outlet.routes inside a DOMContentLoaded callback (a
macrotask). By that point Lit's first-update microtask had already fired,
so firstUpdated() ran with routes = [] and the router was initialised
with no routes.
Fix — LitroOutlet: Replace @property({ type: Array }) routes with a
plain getter/setter. The setter calls router.setRoutes() directly when
the router is already initialised, without going through Lit's render cycle
(which would crash with "ChildPart has no parentNode" because
firstUpdated() removes Lit's internal marker nodes to give the router
ownership of the outlet's subtree).
Fix — app.ts (fullstack recipe template + playground): Set
outlet.routes synchronously after imports rather than inside a
DOMContentLoaded callback. Module scripts are deferred by the browser;
by the time they execute the DOM is fully parsed and <litro-outlet> is
present.
Bug 2 — Click handler never attached on SSR'd pages (LitroLink)
@lit-labs/ssr adds defer-hydration to custom elements inside shadow
DOM. @lit-labs/ssr-client patches LitElement.prototype.connectedCallback
to block Lit's update cycle when this attribute is present. A @click
binding on the shadow <a> is a Lit binding — it is never attached until
defer-hydration is removed, which only happens when the parent component
hydrates. For page components that are never hydrated client-side (because
the router replaces the SSR content before they load), <litro-link>
elements inside them never receive a click handler.
This is why the playground appeared to work: its home page has no
<litro-link> elements. The fullstack generator template does, so clicks
on the SSR'd page were silently ignored.
Fix: Move the click handler from a @click binding on the shadow <a>
to the HOST element via addEventListener('click', ...) registered in
connectedCallback() (before super.connectedCallback()). The host
listener runs in LitroLink's own connectedCallback override, which
executes before the @lit-labs/ssr-client patch checks for
defer-hydration. This ensures the handler is active immediately after the
element connects to the DOM, even for SSR'd elements on first load.
The shadow <a> is kept without a @click binding — it exists for
progressive enhancement (no-JS navigation) and accessibility (cursor,
focus, keyboard navigation).
Bug 3 — _resolve() race condition (LitroRouter)
setRoutes() calls _resolve() immediately for the current URL. If the
user clicks a link before that initial _resolve() completes (e.g. while
the page action's dynamic import is in flight), a second _resolve() call
starts concurrently. If the first call (for /) completes after the second
(for /blog), it overwrites the blog page with the home page.
Fix: Add a _resolveToken monotonic counter. Each _resolve() call
captures its own token at the start and checks it after every await. If
the token has advanced, a newer navigation superseded this one and the call
returns without touching the DOM.
Bug 4 — @property() decorators silently dropped by esbuild TC39 transform (LitroLink)
esbuild 0.21+ uses the TC39 Stage 3 decorator transform. In that mode,
Lit's @property() decorator only handles accessor fields; applied to a
plain field (href = '') it is silently not applied. As a result href,
target, and rel were absent from observedAttributes, so
attributeChangedCallback was never called during element upgrade, leaving
this.href = '' forever regardless of what the HTML attribute said.
Fix: Replace the three @property() field decorators with a
static override properties = { href, target, rel } declaration. Lit reads
this static field at class-finalization time via finalize(), which runs
before the element is defined in customElements, ensuring the properties
are correctly registered in observedAttributes.
Adds a new LitroOutlet.test.ts test file (6 tests) covering the
synchronous and late-assignment code paths, the setter guard, SSR child
clearing, and the LitroRouter constructor call.
Updates LitroLink.test.ts (12 tests) to dispatch real MouseEvents on
the host element (exercising the addEventListener path) rather than
calling the private handler directly by name.
Template fix — @state() declare serverData incompatible with jiti/SSG
The fullstack recipe template used @state() declare serverData: T | null to
narrow the serverData: unknown type inherited from LitroPage. The declare
modifier emits no runtime code, but jiti's oxc-transform (used in SSG mode to
load page files) throws "Fields with the 'declare' modifier cannot be
initialized here" under TC39 Stage 3 decorator mode.
Fix: Remove @state() declare serverData from both page templates. Use a
local type cast in render() instead: const data = this.serverData as T | null.
The property is already reactive (declared as @state() serverData = null in
LitroPage). Updated LitroPage.ts JSDoc and DECISIONS.md to document this
pattern and warn against declare fields in subclasses.
bfd8f9a: Fix fullstack recipe: add base: '/_litro/' to vite.config.ts and extend LitroPage in [slug].ts
Without base: '/_litro/', Vite's compiled modulepreload URL resolver emits paths like /assets/chunk.js instead of /_litro/assets/chunk.js. These requests hit the Nitro catch-all page handler and return HTML, causing a MIME type error that leaves dynamic routes (e.g. /blog/hello-world) stuck on "Loading…".
Also fixes pages/blog/[slug].ts to extend LitroPage (not LitElement) and implement fetchData(), so client-side SPA navigation to different slugs correctly updates serverData.
litro/runtime/... imports instead of @beatzball/litro/runtime/..., and bump nitropack devDependency to ^2.13.1.@beatzball scoped package names following the rename in v0.1.0. Fixes install commands, pnpm --filter flags, npm create commands, and import paths.618a9b8: Rename all packages to @beatzball scope. The unscoped litro package was blocked by npm's name-similarity protection (too close to lit, listr, etc.). All three packages are now published under the @beatzball org scope:
litro → @beatzball/litrolitro-router → @beatzball/litro-routercreate-litro → @beatzball/create-litroThe previously published unscoped litro-router@0.0.2 and create-litro@0.0.2 are deprecated on npm with a redirect notice.
license, repository, and publishConfig fields to all published packages; configure Changesets for automated version management, per-package changelogs, and npm publishing via GitHub Actions.