One piece of middleware answers one question — what language is this reader in? — and hands every route, every screen and every email a translator that already knows the answer.
@pithy-sh/i18n
pithy add i18nServing readers in more than one language, without every screen, error and email growing its own answer to where the words come from.
What it sits on
The translator lives on the request context whether or not you compose this. With nothing installed it is a translator over the English each capability contributed, so a capability writes its key with no null check and no config.
Composing i18n replaces that translator with one that negotiated the reader's locale and merged the catalogs behind it. A project that never composes it is byte-identical to one from before any of this landed.
License
MIT
Read it, run it, change it, ship it in your product. The translations ship with the package rather than being copied into your repository, so a typo fix or a new locale reaches you with the next release instead of stopping at the day you scaffolded.
The honest bit
The reader's tag does two different jobs, and collapsing them is the bug where an Argentine reads Spanish and sees 1,234.56. The catalog locale answers the words, and it falls back — an es-AR reader gets es, because es is what somebody wrote. The formatting locale goes to Intl, and it does not — that reader gets es-AR, which Intl supports whether or not a translator ever did.
So a reader in Buenos Aires gets Spanish sentences and Argentine dates, numbers and currency, from one translator, with nobody writing an es-AR catalog.
The kit ships Spanish today, and no English catalog. That is not an omission: English is the source. An error carries its English message on the wire, a capability contributes its English, and a copied screen carries the English it was scaffolded with. A second copy would be a second place for one sentence to drift.
If you build it yourself
Every i18n library translates a string. What costs you a fortnight is everything around it.
localStorage is absent from a Worker and navigator.language inside workerd is the constant en, so the server and the browser need different chains to reach the same answer.
pt-PT;q=1.0, es;q=0.8 from a project with no Portuguese is a request for Spanish. Reading only the first entry answers English.
Lookup walks layers per key, so changing one sentence is one entry and every key you did not mention keeps arriving with each release.
The payload is both the operator's diagnostic and the fallback for every client that does not translate, so it carries a code and params and the client renders the words.
A missing translation is not a runtime surprise; it is a doctor finding that names the locale and the keys.
Someone who picks Spanish on their phone should not read French on their laptop, which means writing the choice through to their account rather than to storage.
None of that is hard. All of it is a fortnight, and it is a fortnight you spend again the first time a second client appears.
pithy add i18nComposes with
In the dashboard
Which locales you serve, and which messages have no translation yet in each — the same finding pithy doctor fails on, without having to run it.
The dashboard is the admin interface for your own application: whatever this capability writes, your team can look up, check and act on from a browser. Reading is free, forever, with no time limit. Connect the project when you deploy.
Everything else
Each one composes the same way. Nothing you skip costs you anything.
One command installs the package, wires its bindings into every environment your project declares, and runs its migrations.