Fullstack web framework for web components — choose Lit, FAST Element, or Elena via an adapter. File-based routing, streaming SSR, SSG, and Nitro deployment adapters. A standards-based Next.js / Nuxt.js alternative.
npm install @beatzball/litro pnpm add @beatzball/litro
A fullstack web framework for web components, powered by Nitro.
--adapterpages/index.ts → /, pages/blog/[slug].ts → /blog/:slugLitroRouter (URLPattern-based) takes over after SSR with no flickerdefinePageData() runs on the server, serialized to the clientlitro:content virtual module for Markdown blogs with 11ty-compatible frontmatterserver/api/ files using H3 handlers# 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
cd my-app
pnpm install
pnpm dev
// pages/index.ts
import { html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { LitroPage } from '@beatzball/litro/runtime';
import { definePageData } from '@beatzball/litro';
export const pageData = definePageData(async () => ({
message: 'Hello from the server!',
}));
@customElement('page-index')
export default class IndexPage extends LitroPage {
override render() {
const data = this.serverData as { message: string } | null;
return html`<h1>${data?.message}</h1>`;
}
}
| Litro | Next.js | Nuxt.js | |
|---|---|---|---|
| Component model | Lit / FAST / Elena | React | Vue |
| File-based routing | ✓ | ✓ | ✓ |
| SSR / SSG | ✓ | ✓ | ✓ |
| Server engine | Nitro | custom | Nitro |
| Hello World JS | ~8kB | ~90kB | ~60kB |
| Virtual DOM | — | ✓ | ✓ |
| W3C standard comps | ✓ | — | — |
Note on Hello World JS figures: approximate figures based on widely-cited community benchmarks and published framework documentation — not independently measured by this project. Lit's ~5 kB runtime size is documented on lit.dev. Next.js and Nuxt.js figures reflect commonly reported baseline JS payloads for a minimal page and vary by framework version and configuration.
Litro renders all pages server-side before sending HTML to the browser — via Declarative Shadow DOM (Lit/FAST) or light DOM (Elena). Search engines receive fully-rendered content — the same approach used by Next.js and Nuxt.js. Client-side-only web components have SEO limitations; Litro's SSR eliminates them by default.
Full documentation at litro.dev.
| Package | Description |
|---|---|
@beatzball/litro |
This package — core framework |
@beatzball/litro-router |
Standalone URLPattern router (zero dependencies) |
@beatzball/create-litro |
npm create @beatzball/litro scaffolding CLI — fullstack, 11ty-blog, starlight, and supernova recipes |
Apache License 2.0 — Copyright 2026 beatzball.
Changelog
main and the require condition on the root export both pointed at dist/index.cjs, a file absent from every published tarball, so anything resolving the require condition failed with a confusing error about a path it never asked for. The package is ESM only, as the other 32 export subpaths already said, and a CommonJS consumer now gets a clear resolution error naming the package instead.dist, assets, the content type declarations and the changelog. The tarball no longer carries TypeScript sources, test files, vitest config or tsconfig.tsbuildinfo: 575 files and 2.15 MB unpacked become 286 files and 0.77 MB.satori to 0.33.5 and seroval to 1.7.3, clearing four security advisories published on 2026-10-27 and 2026-10-28. Open Graph images render byte-for-byte the same after the satori update: the only API changes across the range are optional new options, and the font this package ships is a static instance, so the variable-font handling added upstream does not apply.4569da2: Warn by name when Lit's hydration support never installed, and raise the vite
dependency range from ^8.0.16 to ^8.1.6.
LitroOutlet.firstUpdated() now reads LitElement.observedAttributes and, if
defer-hydration is missing, logs one console.error naming the cause and the
fix. The hydrate-support module only assigns
globalThis.litElementHydrateSupport; lit-element reads that hook once, when
its own module body runs, and the ?. it reads it with makes a missing hook a
permanent silent no-op. Until now that failed with no error at all: the build
exited 0, the prerendered HTML was correct, and the browser threw the server's
work away and re-rendered every page. Upgrading surfaces the fault in an
existing app with no change to the app. It warns rather than throws, because a
page that re-rendered still works and breaking it would turn a slow page into a
dead one.
The vite range moves off ^8.0.16 now that the ordering fault is fixed, so a
litro dev server starts from a release patched against GHSA-587h-7wmj-h5pm
(High, CVSS 7.5) and four lower-severity advisories. All five are in the Vite
dev server, and all five were fixed in 8.1.6, which Litro could not take while
Vite 8.1+ broke hydration. ^8.0.16 still permitted 8.0.16 itself, so the floor
had to move, not just the lockfile.
src/runtime/client.ts is unchanged in behavior. Its header no longer claims
that importing the hydrate support first is sufficient, and says plainly that
the module is a library barrel and must not be used as an app's client entry —
its named exports need lit-element synchronously, so it cannot be the
two-statement bootstrap that recipes now scaffold.
b8892c3: A page file may now carry a prefix before the bracket: pages/@[handle].ts
scans as /@:handle and pages/@[handle]/[statusId].ts as
/@:handle/:statusId, so Mastodon-style /@alice@example.social URLs are
served. Before, the file scanned as the static path /@[handle] and the URL
answered 404 (issue 234).
A prefixed route now sorts before a bare param sibling, so
pages/@[handle].ts still wins next to pages/[slug].ts. Within dynamic
routes, the route with more literal text in its path comes first; ties keep
the old string order. No route in the recipes or the example apps changes the
page it serves.
A prefix may use letters, digits and _ ~ @ ! & ' , ; = -. Any other
character is rejected at scan time, because the client router or the server
matcher reads it as pattern syntax or receives it percent-encoded.
The suffix form ([id].html.ts, [id]x.ts) is not supported and is tracked
separately. A prefixed catch-all or optional (@[...all].ts, @[[lang]].ts)
and any other stray bracket are rejected at scan time with an error that names
the file. Before, they scanned as inert static paths. A non-page file with a
bracket in its name, such as pages/[id].stories.ts, is now an error too;
move it out of pages/ or start its name with -.
During litro dev, a page scan that fails on reload (one of the errors above,
or a duplicate component tag) is logged and the server keeps serving the last
good routes. Before, it stopped the dev server. litro build still fails.
isDynamic now comes from the bracket syntax, not from the spelling of the
path. A file literally named pages/:slug.ts used to be marked dynamic and is
now a static route for the path /:slug.
The OG image handler's route matcher now escapes the literal runs between params, so a bracket or a dot in a static part of a path matches itself.
dbaa8ea: Refuse two page files that derive the same component tag. pages/index.ts is special-cased to page-home, so adding pages/home.ts next to it used to kill the server at startup with Failed to execute 'define' on 'CustomElementRegistry': the name "page-home" has already been used with this registry, and / went down with /home. The page scan now fails first, in litro dev, litro build and the static build alike, with one clear error that names the tag and every page file that claims it. The root index tag stays page-home for now; renaming it to page-index is a breaking change deferred to the next major.
seroval production dependency from ^1.3.0 to ^1.6.3, resolving
GHSA-p6vx-979v-rg4c (Critical, CVSS 9.8) and GHSA-jp82-f5mq-hwhp (High, CVSS
7.5). Both advisories land on fromJSON(), which the Server Actions serializer
calls on every response it decodes, so this is a reachable path rather than a
baseline entry.shell.ts, and foot no longer closes with <!-- /page-name -->, which
nothing read. Every byte of a comment in the shell was served on every page of
every Litro app. buildShell()'s first parameter is now _componentTag; the
signature is unchanged, so callers do not have to change.4cdba19: Server-render <litro-link>, and put the skip links inside a landmark.
<litro-link> builds its <a href> in render(), and the element was never
registered on the server, so SSR emitted a bare <litro-link href="/docs">
with no shadow root. The href sat on a custom element the browser will not
follow, which left every such link unclickable with JavaScript turned off.
Each adapter now imports its own link module from manifestPreamble(), so the
element is in the server bundle and every app renders a real anchor. An app no
longer needs a side-effect import of @beatzball/litro/runtime/LitroLink.js in
one of its pages to make its links work.
buildShell wrote its skip links as bare anchors at the top of <body>,
outside every landmark, which axe-core reported as region on every page a
Litro app served. They now sit in a <nav aria-label="Skip links">. The label
keeps the landmark distinct from a site's own navigation, and an empty
skipLinks array emits no wrapper. Existing behavior is unchanged: the class
stays on each anchor, so the shell's script still focuses the target and still
hides a link whose target is missing.
Also adds ./adapter/fast/runtime/*.js and ./adapter/elena/runtime/*.js to
the package exports, matching the existing ./runtime/*.js entry.
Updated dependencies [818ba3f]
bfe941d: Add litro mcp serve: serves one agent's tools, and the ui:// documents
litro mcp-app build packed, to an MCP host over stdio.
The command is thin. It compiles the project's source with the project's own
Vite config — not jiti, which mis-orders the decorator passes — loads
@beatzball/litro-agent/mcp-server from the project's dependencies at run time,
and keeps stdout for JSON-RPC and nothing else: the real stream is handed to
the MCP transport and process.stdout.write is pointed at stderr before Vite
starts, so a project's own console.log cannot desync the framing.
Flags: --project <dir> when the host launches the command somewhere other than
the project, --agent <name> when a project has more than one agent, `--apps-dir
--project is what makes a plain host configuration possible. A host starts the
command with a working directory of its own, and at least one desktop host
ignores a cwd field entirely — so without it the only thing that worked was a
shell wrapper doing cd <project> && exec .... A directory that is not a project
is now refused before the bundler starts, with the flag named.
The command opens no port: the Vite dev server it uses runs with its websocket
off, so several Litro MCP servers run side by side. Previously the second one
printed WebSocket server error: Port 24678 is already in use. It also shuts
down when its host disconnects, rather than leaving an orphaned process holding
that port.
Also fixes litro mcp-app build's "not installed" detection, which missed how
Vite 8 words a missing dependency ("Failed to load url ... Does the file
exist?") and so told the reader "could not load", with a Vite stack, instead of
naming the package to install.
1f347da: Export normalizePathname, the one canonicalization every route matcher shares.
A Litro app has two matchers that have to agree: the client router, and the
catch-all handler in server/routes/[...].ts. They were separate
implementations, and neither accepted a trailing slash — so /docs/a/ matched
on neither side. The failure was invisible, because an SSG build writes
docs/a/index.html and a static host answers the trailing-slash URL with 200
and every asset. Only the client missed, and the page rendered blank.
normalizePathname is re-exported from @beatzball/litro-router, which is
dependency-free and where the client router already uses it. A scaffolded app
depends on @beatzball/litro alone, so its handler can now reach the same
implementation:
import { normalizePathname } from "@beatzball/litro";
const pathname = normalizePathname(getRequestURL(event).pathname);
The built-in OG image handler canonicalizes the same way, so an OG image resolves the same route as the page it illustrates.
8b5172c: Give every page a correct box model inside its shadow root.
A document stylesheet does not cross a shadow boundary, so the
box-sizing: border-box reset every recipe ships in its global stylesheet
never reached a page component. <main>, sized width: 100% with horizontal
padding, computed as content-box and measured 438px on a 390px screen.
LitroPage now carries the reset itself: static styles = pageReset in Lit,
and the FAST LitroPage.define() injects it ahead of whatever styles a page
passes. Elena is unchanged — it server-renders light DOM, where the document
stylesheet already applies.
Upgrading fixes an existing app. A page that declares no styles of its own
needs no edit at all. A page that declares static override styles replaces
the inherited value, so compose instead:
import { LitroPage, pageReset } from '@beatzball/litro/runtime';
static override styles = [pageReset, css`...`];
pageReset is exported from @beatzball/litro/runtime for any component that
cannot extend LitroPage.
1348cfa: Keep a page's element registrations in the Nitro server bundle.
Nitro tells Rollup that every module is side-effect free, so a page's
import './components/litro-card.js' — the line that runs
customElements.define() — was deleted from the server bundle. The element was
never registered during SSR, so the server HTML carried a bare <litro-card>
tag with no shadow root and no content, and the build still exited 0.
The page scanner now marks the app's own source files as side-effectful, so
those imports survive. Dependencies are unchanged: they keep deciding through
their own "sideEffects" field.
litro mcp-app build no longer leaves partial output when it fails. It wrote each app's .html and .json as soon as that app packed, so a build that failed on a later app — one with no default export, or two apps claiming one uri — exited 1 with the earlier apps on disk and no manifest.json. Every app is now packed and checked first, and nothing is written unless all of them pass. A failed build leaves the output directory exactly as it was.5c09b18: litro mcp-app build derives each app's ui:// address from its file path
The package name is the authority and the file path is the path, so
mcp-apps/weather/card.ts in a package named playground packs as
ui://playground/weather/card. Filesystem-as-routing is the convention
everywhere else in Litro, and an app declaring its own address was the odd one
out. An explicit uri in the config still wins.
The output mirrors the source tree to match, so mcp-apps/weather/card.ts
writes dist/mcp-apps/weather/card.html — 0.15.0 flattened that to
weather-card.html, which let it collide with a real weather-card.ts. If you
already had app files in subfolders, their output paths move; manifest.json
entries stay relative to the output directory, so reading those needs no change.
One rule with no special cases: rename the package and every address moves
together, which is what an authority is for. The authority comes from the
package rather than the first folder because a ui:// address needs a host AND
a path — with the first folder as authority, a file sitting flat in mcp-apps/
would give host weather-card and an empty path, a different shape from every
nested file. A scoped name contributes only its last part, and a name that
cannot be a uri host means every app in that project must set uri itself.
Names that cannot become an address are refused — unless the app names one.
For a DERIVED address, file and folder names may use letters, digits, ., _,
~ and -; a space, % or a non-ASCII letter is rejected, because a parser
rewrites it and the descriptor would then name a resource under one address
while the host caches it under another. This also closes a silent collision:
big card.ts and big%20card.ts are two raw strings that resolve to one
address. An app that sets its own uri is exempt — the filename is only a
filename then.
Five refusals are NOT exemptible, because an address cannot answer them: a .
or .. segment, which does not survive path normalisation and, with a leading
.., writes outside the output directory; a backslash, which the glob's
absolute form rewrites into / so the loader looks for a file that is not
there; a # or ?, which the module loader reads as url syntax and then
reports the file as missing; a C0 control character or Unicode line separator,
which has no printable form and would appear as itself in the manifest, the
descriptor and every error message (U+007F is allowed: the line is drawn at the
C0 range and DEL sits just outside it); and an app packing to manifest at the
top level, where the index would overwrite its own descriptor
(matched by Unicode case folding, since Manifest.json and manifeſt.json are
both the same file as manifest.json on macOS and Windows).
Problems are reported at once where they can be, before a module is loaded
or a byte is written, instead of one failed build per mistake. The exception is
a character that only breaks a DERIVED address: whether it matters depends on
whether the app declares its own uri, which is not known until the file
loads. assertUniqueUris compares RFC 3986 equivalence — folding host case and
percent-triplet case on top of what new URL() does — and lists every clash
rather than the first.
Dynamic segments are rejected for a derived address, and exempt alongside an
explicit one. [slug], [[opt]] and [...all] mean something in pages/ and
nothing here. index.ts is not the folder root here either.
Also: an error thrown while loading an app file is reported with the file named, instead of escaping the command as a raw stack.
df9971f: Add litro mcp-app build — packs every mcp-apps/*.ts into a self-contained MCP Apps ui:// document.
Each app writes dist/mcp-apps/<name>.html and <name>.json (the resources/* descriptor), plus a manifest.json listing them all. The output is plain files, so any MCP server can serve them; nothing assumes the serving side is a Litro one. --dir and --out override the defaults.
The build fails if two apps declare the same ui:// address: a host caches templates by URI, so a collision does not merge or warn — one app quietly serves the other's markup.
Requires @beatzball/litro-agent, which is loaded from the project at run time rather than imported (it depends on this package, so a static import would be a cycle) — a project without it gets an instruction instead of a resolver error.
15c112d: Add litro docs sync and litro docs check for starlight docs sites.
A starlight site has two sources of truth that can silently disagree: the
Markdown files in the content directory, and the hand-written sidebar array
in server/starlight.config.js. Add a page and forget the config and the page
is live but unreachable — no link, no prev/next, and nothing says so.
litro docs sync rewrites the sidebar from your pages' frontmatter,
using sidebar.order, sidebar.group and sidebar.label.litro docs check changes nothing and exits 1 on drift: a page with no
title or description, a body that opens with its own # heading
(duplicating the frontmatter title), colliding slugs, a page missing from the
sidebar, or a sidebar link pointing at no page. Suitable as a CI step.This also makes the recipe's own documentation true. It has always claimed
sidebar.order "controls sort order within the sidebar group" and that
sidebar.label overrides the sidebar text — neither field was read by
anything. Both work now, and sidebar.group joins them.
sync refuses to run when the sidebar has several groups but no page declares
one, since that would silently collapse a hand-built navigation into a single
group. It prints the group each page currently belongs to so the structure can
be restored by copy-paste, and takes --force for a deliberate flatten.
83c8d5a: Remove the source export condition. It pointed consumers at the package's
TypeScript source, and Vite does not transpile TypeScript that lives inside
node_modules — so an installed app got Lit's @customElement decorators
emitted as raw syntax:
(@at(`litro-outlet`) class extends rt { ... })
No browser can parse that. The client bundle died with Invalid or unexpected token, nothing hydrated, and the page rendered blank — while the build still
exited 0 and the prerendered HTML was still correct.
This affected any app whose vite.config.ts listed 'source' in
resolve.conditions (which every create-litro template did) on Vite 8.
Vite 5 was unaffected, so apps only broke on upgrading. Four of the six recipe
variants were affected: Lit and FAST apps broke on @customElement / @attr;
Elena apps use no decorators and were fine.
Existing apps need no code change — upgrading is enough. Leaving 'source' in
resolve.conditions is now harmless, because the condition no longer matches
and resolution falls through to the compiled output.
Updated dependencies [83c8d5a]
bodyScript hook: pages can return an optional bodyScript string (raw HTML, typically a synchronous <script>) from definePageData(), and the shell emits it at end-of-body immediately before the app-bundle script. This gives pages a synchronous, pre-hydration slot — symmetric with seoHead for <head> — for filling server-unknowable values (e.g. times in the user's local timezone) on first paint. Like seoHead/seoTitle, the field is stripped from the serialized __litro_data__ client JSON. Pages that don't use it are byte-for-byte unchanged.e576f80: Bump nitropack from ^2.13.1 to ^2.13.4, resolving Medium-severity advisories GHSA-5w89-w975-hf9q and GHSA-9phm-9p8f-hw5m.
16d2705: litro dev now serves the live client entry (/app.ts) through Vite instead of a stale pre-built dist/client/app.js — source edits reflect in the browser without a rebuild (issue 97). The new litroViteDevConfig()/warmupLitroViteServer() helpers (@beatzball/litro/runtime/vite-dev.js) centralise the dev Vite config: dev base: '/' so module URLs stay clear of the /_litro/ static mount, dependency pre-optimization and page warmup to prevent mid-load full reloads, and watcher ignores for .nitro//.litro/ so Nitro regenerating its types no longer forces a full browser reload. The actions Vite plugin now also stubs @beatzball/litro/actions/server by resolved path, which live-source dev requires to keep node:crypto imports out of the browser graph.
Backwards compatible for existing apps: the live entry only activates when the app's server/middleware/vite-dev.ts builds its Vite server from litroViteDevConfig() (which sets LITRO_DEV_LIVE_ENTRY). Apps still running a previously-scaffolded middleware keep their prior dev behavior unchanged after upgrading; update the middleware to the current template to opt in to the live entry.
Updated dependencies [16d2705]
createStreamEncoder, createStreamDecoder, serializeValue, deserializeValue, isAsyncIterable) as a public @beatzball/litro/stream subpath so Server Actions and the agent layer share one protocol.index.md), so docs/setup.md and blog/setup.md collide — previously the slug-keyed index silently kept only one of them, dropping the other from every listing and lookup.7602471: Server Actions milestone 2: progressive-enhancement forms and streaming returns.
actionUrl() renders plain <form method="post"> targets for actions; without JS the endpoint answers with a Post/Redirect/Get flow (one-shot litro-form-error cookie, read via getFormErrors()); with JS, enhanceForms() (@beatzball/litro/actions/form-client) upgrades submits to seroval RPCs and reports results via litro:action-success / litro:action-error events. Opt-in csrf: 'token' double-submit mode with csrfToken(); form targets require a defineAction input schema.AsyncIterable (async generators included); responses stream as application/x-ndjson seroval cross-JSON lines and callAction yields chunks incrementally, preserving object identity across chunks.x-forwarded-host; .mjs action modules keep their extension in generated manifests; new @beatzball/litro/actions/server subpath (server-only helpers, stubbed in client builds).Upgrading from 0.10.x or earlier: Server Actions (typed RPC, forms, and streaming together) are new in 0.11.0 and fully opt-in — upgrading the dependency changes nothing until you wire the feature, and there are no breaking changes. Adding actions to an existing app is six one-time edits, documented in the Server Actions guide's Setup section. One of them, server/plugins/litro-actions.ts, must be committed rather than gitignored (Nitro scans server/plugins/ before the build hook that would regenerate it — an ignored copy makes actionUrl() throw on a fresh clone's first dev run). Apps scaffolded with @beatzball/create-litro 0.7.0 or later come pre-wired.
17a0eca: Add Server Actions (typed RPC). Export an async function from a *.server.ts module and call it from anywhere: in-process during SSR, or as a typed HTTP call (POST /__litro/action/:id) from the browser — the Vite plugin rewrites client imports into generated stubs, so server code and its dependencies never enter the client bundle. Includes defineAction with Standard Schema input validation, seroval JSON-mode serialization (Date/Map/Set/BigInt/circular refs round-trip), default-on CSRF protection (custom header + Sec-Fetch-Site/Origin checks), structured error forwarding via LitroActionError, and a Nitro build plugin that scans action modules and generates the endpoint handler. New subpath exports: @beatzball/litro/actions, @beatzball/litro/actions/client, @beatzball/litro/actions/handler, @beatzball/litro/plugins/actions, plus litroActionsPlugin from @beatzball/litro/vite. Also fixes a latent dev-server reload loop by making generated-file writes idempotent in the page scanner.
1f4d669: Upgrade Vite from 5 to 8 (Rolldown + Oxc bundler). Builds are substantially faster and the toolchain moves onto the Oxc transform pipeline. The Lit decorator transpile (experimentalDecorators / useDefineForClassFields from tsconfig.json) is honored by Oxc exactly as it was by esbuild, so client bundles are unchanged. No Litro config changes were required — build.rollupOptions is accepted via Vite's compatibility layer.
Breaking for consumers: Vite 8 requires Node ^20.19.0 || >=22.12.0 (Node 18 is no longer supported). Existing projects should bump their own vite dependency to ^8 and ensure they are on a supported Node version. See the Vite 8 upgrade note for details.
@elenajs/ssr dependency); fix the JSDoc on LitroConfig and the comment in plugins/pages.ts that misdescribed adapter selection (the only working path is process.env.LITRO_ADAPTER, not a runtimeConfig.litro.adapter block).execa runtime dependency (the CLI uses child_process.spawn directly per cli/index.ts:18).a7d2dc1: Fix: emitted .d.ts files for the Elena adapter no longer reference a nonexistent ElenaClass type from @elenajs/core. Downstream consumers with strict TypeScript can now extend LitroPage without TS4113/TS2339 errors.
The fix removes a stale ambient module declaration (packages/framework/src/adapter/elena/env.d.ts) that was added against @elenajs/core@1.0.0-rc.13 and never cleaned up after the upgrade to 1.0.0. With the shim removed, LitroPage/LitroLink/LitroOutlet now extend the real ElenaElementConstructor exported by @elenajs/core@1.0.0, and consumer typecheck passes.
The page-manifest generator was also tightened so the generated routes.generated.ts and server/stubs/page-manifest.ts files typecheck under strict + bundler moduleResolution: relative .js import specifiers replace absolute .ts paths, and pageModules is exported as Record<string, Record<string, unknown>> so the catch-all handler's pageModules[route.filePath] lookup no longer trips TS7053. Generated files lead with // @ts-nocheck for any remaining generator artefacts.
<script> that polls /_litro/_litro-version.json is now only injected when the content plugin is active (signaled via LITRO_HAS_CONTENT=true), eliminating 404 noise in dev for apps without Markdown content. Polling interval relaxed from 300ms to 2.5s. Internally, ShellOptions.devMode is renamed to ShellOptions.contentDevPolling; external consumers of buildShell() should update the option name.@elenajs/ssr dependency), progressive enhancement (no hydration), no DSD polyfill. Includes runtime components (LitroOutlet, LitroLink, LitroPage), manifestPostamble API for post-import component registration (currently unused by Elena, which registers via the bundled customElements shim during page module imports).035912e: feat: skip links API — skipLinks array on ShellOptions, DEFAULT_SKIP_LINKS constant, SkipLink type export
feat: SPA focus management — focus outlet after page swap, announce new page title via aria-live region
8dfa849: Add shared content + UI packages and dev-mode content live reload
docs/content/ into new private workspace @beatzball/litro-docs-content so both the SSG docs site and the upcoming SSR docs site can share the same Markdown sourcedocs/src/ into new private workspace @beatzball/litro-docs-ui (Lit components, SEO utilities, highlight helpers) for the same reasondevMode option to buildShell / ShellOptions: when true, injects a polling script that fetches /_litro/_litro-version.json every 2.5 s and reloads the browser when the version changeslitroContentPlugin) now writes dist/client/_litro-version.json on every Markdown file change and sends a litro:content-update WebSocket event, enabling live browser reload on content edits in dev mode"sideEffects": ["./src/components/*.ts"] to @beatzball/litro-docs-ui so Rollup does not tree-shake side-effect-only custom element registration importsf8b1e6d: Phase 2: docs-ssr fullstack SSR site, LitroLink styles, router scroll-to-top, content plugin production fix
docs-ssr/ workspace — fullstack SSR replica of the docs site using shared docs-content and docs-ui packagesstatic override styles with text-only inheritance (fixes double anchor point on CTA buttons)import.meta.url resolves incorrectly after Rollup bundlingPORT env var alongside --port flag for reliable dev server port binding?hidden attribute pattern replaces structural ternaries for SSR hydration safety; spaNav prop enables SPA navigation in SSR contextsAccept: application/json returns pageData as JSON for client-side SPA data fetchingssrPreset() required in nitro.config.ts for correct production output directory65d309a: fix(content): add rehype-raw to content parser pipeline
HTML blocks containing blank lines (e.g. side-by-side code sections) were being split at
blank line boundaries after remark's MDAST→HAST step. Adding rehype-raw to the pipeline
ensures raw HTML blocks are correctly re-parsed as a unit.
f768829: fix(shell): escape </script in serverDataJson to prevent early script tag termination
When page data contains rendered HTML (e.g. a CHANGELOG entry that mentions </script> in a
code span), the HTML parser terminates the <script type="application/json"> element early at
the first literal </script sequence. This corrupts the embedded JSON, causing getServerData()
to return null and the page to render with no server data.
Fixed by replacing </script with <\/script in the JSON string before embedding it in the
HTML shell. JSON parsers treat \/ as /, so the data round-trips correctly.
619ec3c: 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.
d5d0b41: fix: strip seoHead/seoTitle from litro_data JSON; add .xml MIME type to litro preview
Pages returning seoHead from definePageData() commonly include a JSON-LD <script> block. The closing </script> inside the JSON string caused the browser HTML parser to terminate the outer <script type="application/json"> element early, leaking the remainder of the JSON as visible text and causing getServerData() to return null. Fixed by destructuring seoHead and seoTitle out of the page data before JSON.stringify — these fields are only needed server-side to build the <head>.
Also adds .xml to the MIME type map in litro preview's static file server, preventing browsers from prompting a download for .xml routes (e.g. RSS feeds, sitemaps) when previewing an SSG build locally.
Updated dependencies [619ec3c]
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.ymllitro-content.js stubs using a path relative to the stub file rather than an absolute path. This prevents machine-specific directory paths from being baked into generated files.litro dev and litro preview now default to port 3000 and auto-increment when that port is taken, rather than crashing with an opaque EADDRINUSE error. A connect-based TCP probe detects all listeners including Docker Desktop's port-forwarding on macOS. Passing --port/-p explicitly still errors out if that port is already in use.78fdaf6: Fix hash-only navigation re-renders, shadow DOM scroll-to-hash, SSG preview, and routeMeta.head injection.
@beatzball/litro-router
popstate events (fragment/TOC links) no longer re-render the current page. LitroRouter now tracks _lastPathname and skips _resolve() when only the hash changes.location.hash is set, the router waits for updateComplete then scrolls to the target element via _scrollToHash(). Heading elements inside shadow roots are located using _findDeep() recursive shadow DOM traversal — native document.getElementById() cannot cross shadow boundaries.@beatzball/litro
createPageHandler now forwards routeMeta.head to buildShell(). Previously, stylesheet links and inline scripts declared in routeMeta.head were silently dropped from the HTML <head>.litro preview now serves SSG builds from dist/static/ with a built-in Node.js static file server (clean-URL resolution, correct MIME types). Previously only SSR builds were handled and litro preview after an SSG build exited with "No production build found".spawn/spawnSync calls with an args array and shell: true now join into a single command string.Updated dependencies [78fdaf6]
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.
Updated dependencies [76d3bc7]
@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.