Templates
Embedded scaffolding used by the CLI to create projects and generate files. Keeps new apps consistent and zero-config.
Overview
- Repo source of truth lives under
orchestrators/bun/resources/templates/**. - Generated package assets live under
orchestrators/bun/assets/templates/**and are embedded into the Bun CLI package. webstir initlays down a server-first project from thefullstarter by default.- Generators add files in the right place with sensible defaults.
Layout
Created by webstir init according to the starter:
full: a home page with one form, and a server with one view and the route the form posts to, with client-nav onspa: a home page, with client-nav onssg: a docs site: home, about and docs pages, Markdown content and its stylesapi: a server entry
The starter only chooses the starting files. After init, the app's layers come from those files: pages when src/frontend/ exists, a server when src/backend/index.ts exists.
Typical frontend scaffold:
src/frontend/app/app.html: the shell every page merges intosrc/frontend/app/app.css: the app's stylessrc/frontend/app/app.ts(optional): the app's own code for every page, such as a theme or a menusrc/frontend/pages/<page>/index.html|cssplusindex.tsfor standard pagessrc/frontend/{content,images,fonts,media}/**
What the build adds to every page, so the app holds none of it:
- The app bundle (
/app/app.js): the enabled features (webstir.enable), the error reporter in an app with a server, andapp.tswhen there is one. A page loads it whenever there is anything in it. - The app's styles (
/app/app.css), linked ahead of the page's own stylesheet:app.cssplus the stylesheets of enabled features. Pages don't importapp.css. - In
webstir watch, Webstir's live-update and reload clients (/hmr.js,/refresh.js), which the package serves and publish leaves out.
A shell that already names one of these keeps its own tag.
Typical backend scaffold:
src/backend/index.tssrc/backend/module.tssrc/backend/jobs/**src/backend/tests/**
Conventions
- Base HTML requires a
<main>insrc/frontend/app/app.htmlfor page merge. - Page folder names must be one non-empty path segment without separators,
./.., NUL bytes, or platform-reserved names and characters. - Each page has
index.htmlandindex.css; standard pages also haveindex.ts, whileadd-page --no-scriptomits it. - Backend entry is
src/backend/index.ts. - Fresh
apiandfullstarters keepsrc/backend/index.tsthin and use it to boot the package-managed Bun runtime. - Manifest-backed route and demo logic lives in
src/backend/module.ts. - The default app primitives are documented in Primitives; treat that page as the naming contract for pages, forms, actions, fragment targets, request-time views, and auth-gated routes.
- For optional app features, prefer absolute app-asset imports such as
await import('/app/search.js')so dev and publish paths stay aligned. - Start with
fullwhen the app needs forms, redirects, auth, or server-rendered documents; start withspaorssgfor pages without a server, which publish as a static site.
Bindings
A page's index.html, the app shell and partials bind data with attributes. A binding is a path of names joined by dots, such as client.name, read from the view's data. The build checks every path against the view's schema and fails with the file and line of one that does not exist. Every value is escaped, and the attributes are not in the rendered page.
| Attribute | Meaning |
|---|---|
data-text="path" |
Replaces the element's content with the value. What is written there is a placeholder. |
data-attr-<name>="path" |
Sets the attribute. true adds it bare, and false, null or nothing removes it. |
data-if="path", data-if="!path" |
Keeps the element only when the value is truthy, or falsy with !. |
data-each="items as item" |
Repeats the element for each entry, with item in reach inside it. |
data-include="name" |
Replaces the element's content with src/frontend/app/partials/<name>.html. |
data-with-<name>="path" |
Gives a path a name, in reach of the element's other bindings and inside it. |
data-with-<name>="'text'" |
Gives a name to text written in single quotes. |
data-props="path" |
Passes the value to the island on the element, as JSON. See Islands. |
A partial reads whatever names are in reach where it is included, so data-with-<name> is how one partial serves different data:
<!-- src/frontend/app/partials/field.html -->
<label data-text="field.label">Label</label>
<input data-attr-name="field.name" data-attr-value="field.value" />
<p data-if="field.error" data-text="field.error"></p><div data-include="field" data-with-field="form.email"></div>
<div data-include="field" data-with-field="form.phone"></div>Text in single quotes is for what the template itself knows, such as a button's label. The build writes it into the page, so it costs nothing at request time:
<a class="button" data-include="button" data-with-label="'Edit'" data-attr-href="client.editUrl"></a>- Names are lowercase. HTML lowercases attribute names, so
data-with-clientRowgives the nameclientrow. Use lowercase letters, digits and underscores. - The nearest name wins. A name given inside a loop hides the loop's item of the same name, and a loop inside hides a name given outside it.
- Names on one element do not read each other. Each is given from what is in reach outside the element, and from its own loop's item when it has a
data-each. - A loop is read first.
data-eachcannot read a name its own element gives, and the build says so. Give the name on an element outside. - Text is not data. A name given text works in
data-text,data-attr-*anddata-if, where empty text is falsy. It cannot be looped over or passed to an island. - Errors name what was read. A path through a given name is checked like any other, and the message shows the real path, such as
form.phone, at the partial's line. - A name given and never used is not checked. A misspelled path shows up where the name is used, or not at all if it never is.
- Names stay in their file's template. A page that renders in the browser cannot read a name the app shell gives.
TypeScript
src/frontend/tsconfig.jsonandsrc/backend/tsconfig.jsonare one line each, extending@webstir-io/webstir-frontend/tsconfig.jsonand@webstir-io/webstir-backend/tsconfig.json, which hold the compiler settings. Stylesheet imports (CSS modules) are typed by the frontend package's declarations. Add your own options beside theextends.- A backend with no
tsconfig.jsonis type-checked with the package's settings. - ESM-only; compiled via the active provider packages.
- Dev output keeps source maps for local debugging; publish strips them.
- Dynamic imports load at runtime. Keep
/app/...imports absolute for assets undersrc/frontend/app/.
CSS & Assets
- Plain CSS by default; optional CSS Modules in publish.
@importand asset URLs are resolved; files copied to outputs.- Place static app assets under
src/frontend/app/*. - Place Images, Fonts, and Media under
src/frontend/{images|fonts|media}/**.
Inline Scripts
- A script tag marked
data-webstir-inlinenames a TypeScript or JavaScript source that the build bundles and writes into the tag itself, so it runs before anything paints. Use it for the small things that must be right on the first frame, such as a theme or a computed backdrop:<script data-webstir-inline src="./scripts/first-paint.ts"></script>insrc/frontend/app/app.htmlruns on every page; the same tag in a page'sindex.htmlruns on that page.- A relative
srcresolves against the file that contains the tag; a leading/resolves againstsrc/frontend, the way/app/app.jsdoes.
- The bundle is an immediately-invoked script with its imports included, readable in development and minified on publish.
webstir watchrebuilds the page HTML when the source or anything it imports undersrc/frontend/appor the page changes. - The build keeps the source path in the attribute (
data-webstir-inline="src/frontend/app/scripts/first-paint.ts") and dropssrc; client-side navigation leaves inline head scripts in place, so they run on full loads only. - A missing source fails the build. A published (minified) bundle over 16 KB gets a
frontend.inlineScript.largewarning, because it travels with every page that includes it; the readable build output is not measured.
Client Error Reporting
- Every app with a server reports its pages' browser errors: the app bundle every page loads includes Webstir's reporter, which listens for
windowerrorandunhandledrejectionand reports toPOST /client-errorsusingsendBeacon(fallback tofetch). Nothing of it lives in the app. - An app without a server has nowhere to report to, so it is off there.
- Behavior:
- Throttled: max 1 event/second; capped at 20 per page session.
- Deduped: repeats suppressed within 60s using a fingerprint of type|message|file:line:col|stack-hash.
- Correlation: includes a client correlation id; the server also accepts
X-Correlation-ID.
- Where reports go:
webstir watchprints each report in the terminal next to the build output; the Bun backend runtime logs it at error level, so apps with a server have a sink in production. - Turn it off with
"webstir": { "enable": { "clientErrors": false } }inpackage.json, or on in an app without a server that reports elsewhere withtrue.
Generators
add-page
- Command:
webstir add-page <name> --workspace <path> [--no-script] - Calls the canonical
@webstir-io/webstir-frontendhelper to scaffoldindex.html|cssplusindex.ts;--no-scriptleaves outindex.ts. - Does not modify existing pages or
app.html. - Name validation: rejects control characters, trims surrounding spacing, preserves case and internal spaces, and requires one portable non-empty path segment (not
.or.., a platform-reserved name, or a name containing reserved characters).
add-test
- Command:
webstir add-test <name-or-path> --workspace <path> - Uses the canonical
@webstir-io/webstir-testinghelper to create<name>.test.tsunder the nearesttests/directory; older published installs fall back towebstir-testing-add. - Validates every path segment for portable filenames before creating directories or files.
- Works for both frontend and backend tests.
Backend Template
src/backend/index.tsstarts the server with the package's defaults:createDefaultBunBackendBootstrap({ importMetaUrl: import.meta.url }). Pass options there to change them, such as a session store orresolveRequestAuth.src/backend/module.tsholds the app's routes and views (the full starter's demo module; the api starter adds one when it needs it)..env.examplelists the server's settings;.gitignorekeepsdata/,.webstir/and.envout of git. See Environment.- Health endpoints:
GET /api/healthand/healthz; a readiness probe at/readyzthat returns the manifest summary; request counts and timings at/metrics. - The batteries come with the package, not the template:
- a database with migrations from
src/backend/migrations(Use the Database) - sessions kept in it (Keep Sessions)
- jobs on a schedule or queued (Run Jobs)
- email (Send Email) and file storage (Store Files)
- email-code sign-in, with
webstir enable sign-in(Add Sign-In)
- a database with migrations from
- Bearer-token auth for an API with its own identity provider:
resolveRequestAuth: (request) => resolveBearerAuth(request), from@webstir-io/webstir-backend/auth/bearer, reading theAUTH_*settings. Unsupported algorithms, malformed tokens, bad signatures, wrong issuer or audience, and invalid time claims fail closed.
Publish Outputs
- Per page:
dist/frontend/pages/<page>/index.htmlin an app with a server;dist/frontend/<page>/index.html(anddist/frontend/index.htmlforhome) in an app without one - Fingerprinted assets:
dist/frontend/pages/<page>/index.<timestamp>.{css|js} - Per-page
manifest.jsonlisting hashed asset names. - App assets copied to
dist/frontend/app/*.
Customizing Templates
- Edit templates under
orchestrators/bun/resources/templates/. - Regenerate the shipped package assets with
bun run --filter @webstir-io/webstir buildorcd orchestrators/bun && bun scripts/sync-assets.mjs. - Use
bun run --filter @webstir-io/webstir check:assetsto verify the generated tree is still in sync. - Keep conventions intact (page structure, base HTML
<main>, server entry path). - After changes, rebuild the CLI to embed updated templates.
Related Docs
- Solution overview — solution
- Primitives — primitives
- CLI reference — cli
- Engine internals — engine
- Pipelines — pipelines
- Workspace and paths — workspace
The full demo (examples/demos/full) adds /lifecycle, demonstrating the optional page setup
export and cleanup scopes, and the progressive-enhancement form flow. Client-nav itself ships in @webstir-io/webstir-frontend
(packages/tooling/webstir-frontend/src/features/); apps import it, so there are no
copies to refresh.