Tooling, Images and Translations
Recipes: wisp add <name>
A recipe is the app's own, in add/<name>/; Wisp ships none and has no runtime part in it. wisp add lists recipes, wisp add sqlite applies one; a name with no recipe is an npm package. A recipe wires in an existing crate; the app never gets homegrown database or auth code.
add/<name>/recipe lines (# comments): dep (a [dependencies] line), env (a .env.example line), file <path> (copies add/<name>/<path> to <path>), note (printed after). Nothing runs, nothing downloads. Idempotent: an existing crate or key is skipped, an existing file stays unless --force, a path outside the app is refused, every file is checked before any is written.
# add/sqlite/recipe
dep sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] }
env DATABASE_URL=sqlite://app.db?mode=rwc
file src/db.rs
file src/routes/api/time/+server.rs
note Call crate::db::open().await? in init (src/hooks.rs), then GET /api/time.
// add/sqlite/src/db.rs: `pub` items of src/db.rs are in every route
pub type Pool = sqlx::SqlitePool;
pub async fn open() -> Result {
let url = wisp::env("DATABASE_URL").or_status(500)?;
wisp::provide(Pool::connect(&url).await?);
Ok(())
}
pub fn pool() -> &'static Pool {
wisp::state::<Pool>()
}
// add/sqlite/src/routes/api/time/+server.rs
async fn get() -> Result<Response> {
let (now,): (String,) = sqlx::query_as("select datetime('now')")
.fetch_one(pool())
.await?;
Ok(Response::text(now))
}
Other recipes (add/<name>/recipe, files beside it):
# add/postgres: db.rs as above with sqlx::PgPool and `now()::text`
dep sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres"] }
env DATABASE_URL=postgres://user:pass@localhost/app
file src/db.rs
# add/redis: src/cache.rs does wisp::provide(redis::Client::open(url)?)
dep redis = { version = "0.27", features = ["tokio-comp"] }
env REDIS_URL=redis://127.0.0.1/
file src/cache.rs
# add/redis-store: src/store.rs is `impl wisp::Store` over redis (HSET/HDEL on a
# hash per table, HGETALL to load); init calls wisp::store(RedisStore::new(url))
dep redis = { version = "0.27", features = ["tokio-comp"] }
env REDIS_URL=redis://127.0.0.1/
file src/store.rs
# add/kv-store: src/store.rs is `impl wisp::Store` over a KV's REST API
# (a key per row, `table/id`), with `changes` from a list of recent keys
env KV_URL=https://example.invalid/kv
file src/store.rs
# add/tailwind: src/app.css is `@import "tailwindcss";`
file src/app.css
note Wisp builds app.css with Tailwind when it imports it.
Wisp does not ship or maintain integrations: recipes are yours to write, change and share as folders.
Component Kit: wisp ui add
wisp ui list # what there is
wisp ui add button dialog tabs # into src/components
Copies components into src/components; the copy is the app's to change. An existing file stays unless --force.
| Component | Use | Browser code |
|---|---|---|
Button | <Button variant="secondary" kind="submit">Save</Button>; href makes it a link | none |
Badge | <Badge tone="success">Paid</Badge> | none |
Card | <Card title="Tea">…</Card> | none |
Input, Textarea | <Input label="Email" name="email" kind="email" hint="…" problem={p} /> | none |
Checkbox, Switch | <Switch label="Dark" name="dark" checked /> (checkbox, role="switch") | none |
Select | <Select label="Drink" name="drink"><option>Tea</option></Select> (native) | none |
Accordion | <Accordion title="Q" group="faq">A</Accordion> (<details name>) | none |
ClientOnly | <ClientOnly fallback="Loading…"><Chart /></ClientOnly>: server sends the fallback, children wait in an inert <template> and are drawn on mount | onMount |
Dialog | <Dialog id="d" title="Sure?" trigger="Delete">…</Dialog> (<dialog>, commandfor) | none |
Menu | <Menu id="m" label="Actions"><button role="menuitem">Edit</button></Menu> (popover) | arrow keys |
Tabs | <Tabs id="t" labels={["A", "B"]}><div>…</div><div>…</div></Tabs> | arrow keys |
Tooltip | <Tooltip id="tip" text="Saves it"><button>Save</button></Tooltip> | aria-describedby, Escape |
Toast | <Toast message={flash.unwrap_or_default()} /> in the layout; scripts send dispatchEvent(new CustomEvent('toast', { detail: 'Saved' })) | the list |
- Props are typed. Styles are scoped and read the demo's tokens with fallbacks (
var(--accent, #896ce0),--panel,--line,--ink,--radius,--danger,--success,--warning), so an app's:rootrestyles them all. - Native elements first (
<dialog>,popover,<details>,<select>, checkboxes): browser keyboard, focus and screen reader support, no JavaScript for ten of fifteen. The rest follow WAI-ARIA patterns. A test builds all of them with no accessibility warnings.
Images
<img src="$lib/photo.jpg" alt="…"> (a file of src/lib) or src="/photo.jpg" (from static/), a JPEG, PNG or WebP with a quoted src, is filled in by the compiler before parsing (wisp-build/src/image.rs):
- Always:
widthandheightfrom the file header (a small reader for the three formats, EXIF orientation included; no image crate), unless the tag sets either. No layout shift. wisp build: each image is written as WebP at up to three widths (640, 1280, 1920, never wider than it) to.wisp/img/<hash>-<w>.webpby a pinned cwebp (libwebp 1.6.0, downloaded once to~/.wisp/bin, SHA-256 checked, as Tailwind is;$WISP_CWEBPoverrides). Names are the content hash, so a second build encodes nothing. Release embeds them (asrc/liboriginal too), served under/_app/img/as immutable, and addssrcset,sizes="100vw",loading="lazy",decoding="async". Attributes the tag has stay.- Durable: without cwebp (no network, no build for the platform, failed encode) the build warns and the tag gets no
srcset; the original is served, sized. A JPEG whose EXIF turns it gets no WebP. A missing$lib/file is a build error. - Dev serves the original (
/_app/img/lib/photo.jpgfromsrc/lib) with onlywidthandheight. <img priority …>(bare, as in next/image) is above the fold: the attribute goes,fetchpriority="high"comes, no lazy.<img data-wisp-raw …>stays as written (a$lib/src still gets its URL). Asrcwith a hole, or another site's, is left alone.- Opt-in
aviffeature (wisp-cli,wisp-build; off, so the default tree is unchanged):wisp buildalso writes AVIF widths in process withravifandimage(pure Rust, slow, hence opt-in), and the tag becomes<picture><source type="image/avif" srcset sizes>…<img …></picture>. Without the files nothing changes. - Opt-in
imgfeature (wisp; depsimagewith png, jpeg, webp decoders only, because resizing needs decoders): asrc/routes/_img/+server.rswithwisp::img::serve::<crate::App>(cx)answers/_img?src=/photo.jpg&w=640&q=75. Onlystatic/files (embedded in release),wfrom a fixed list (next/image's),q1 to 100, no.., files over 10 MB or 40 megapixels refused, a decoder panic is a 400, results cached in memory (64 MB). A route like any other: no hot-path code, nothing compiled without the feature. - Cost: none without local images; a header read per image per build.
Translations
One JSON file per locale in src/locales, flat or nested keys:
{ "cart": { "title": "Your cart",
"items": "{count, plural, =0 {No items} one {# item} other {# items}}" },
"hi": "Hello, {name}!" }
<h1>{t("cart.title")}</h1>
<p>{t("cart.items", count)} {t("hi", name = user.name)}</p>
<button on:click="n++">{:t('cart.items', n)}</button>
- Messages are ICU's subset:
{name}, and{n, plural, …}with=Nand the locale's CLDR cases (one,few, ...;otherrequired;#is the count).'{'is a brace,''an apostrophe. A plural counts a whole number. - Values: one, for a message with one placeholder; else by name (
name = expr, or a variable of that name alone). In a script: one, or an objectt('hi', { name }). - Build checks, at file and line: a key missing from any locale, a placeholder one locale has and another lacks, a case the language lacks, an unknown key, mismatched values.
- Compiled to an index:
t("cart.title")is a&'static strfrom a table per locale (can be a&strprop); values are written as displayed. No key lookup at run time. - Locale order: the route's
[[lang=locale]](built-in matcher of the app's locales), thelangcookie,Accept-Language, the default (first file, orwisp::default_locale("fr")?ininit).cx.locale()says it,<html lang>is set, aCACHEd page is kept per locale. - A page's scripts get only the messages they use, in its locale, with the page; plurals follow
Intl.PluralRules.src/libmodules cannot callt: pass them text. - Switchers:
{@html wisp::switcher(cx)}is a<nav class="wisp-locales">of links to the page in each locale, named in its own language; or{#each wisp::locales().iter() as l}<a href={wisp::localize(cx.path(), l)}>{l}</a>{/each}(/fr/aboutto/en/about).
Routing and the rest are opt-in, i18n = [...] in [package.metadata.wisp] of Cargo.toml, each string name value, baked at build (no setting, no code):
i18n = ["default en", "prefix as-needed", "domain example.fr fr", "missing warn"]
| Setting | Behavior |
|---|---|
prefix optional | The default. /about and /fr/about both answer |
prefix always | Every page has its locale; /about redirects (307, GET and HEAD, query kept) to the visitor's (cookie, then Accept-Language): /fr/about |
prefix as-needed | Default locale has no prefix and is what an unprefixed page gets (no detection: a URL names one language); /en/about redirects (308) to /about |
domain example.fr fr | One per locale: a request's locale is its Host's, localize gives //example.fr/about, redirects are off. A [[lang=locale]] segment still wins |
default fr | Locale for a request naming none, instead of the first file (wisp::default_locale in init still overrides) |
missing warn | A locale lacking a key the default has uses the default's message and the build warns (file:line: "key" is missing); a key the default lacks, or a differing placeholder, is still an error. missing error is the default |
wisp::localizefollows the prefix mode, so links and the switcher need no change. Redirects are emitted only in pages under[[lang=locale]]: other routes pay nothing.{@html wisp::alternates(cx)}in a head writes<link rel="canonical">and analternatewithhreflangper locale plusx-default; addresses start withSITE_URL, else the request's host (a domain's host for its locale)./sitemap.xmllists every page in every locale withxhtml:linkalternates (the default's unprefixed underas-needed;prefix optionallists the prefixed pages).wisp build --staticwrites each locale's pages (index.html,fr/index.html), plus the unprefixed ones unlessprefix always.entries()of a page under[[lang=locale]]lists the values of its other params.<html lang>and, for right-to-left languages (ar, he, fa, ur...),dirare set per request;wisp::dir("ar")saysrtlfor your own elements. Pages under[[lang=locale]]are not baked.- Formatting (small tables, no dependency):
wisp::format_number(n, l)(1,234.5,1 234,5,1.234,5),format_money(n, "EUR", l),format_date(d, l)(10/4/2026,04/10/2026,4.10.2026),format_date_long(d, l)(October 4, 2026,4 octobre 2026).dis Unix seconds,"2026-10-04"or(2026, 10, 4);liscx.locale(). A language without a table isen(numbers) or ISO 8601 (dates). - Example:
examples/i18n.
Is This Page Useful?