You need: i18n composed, and somebody who speaks the language.
The key grammar
Catalog keys is the grammar in full, supported locales is what already exists, and checking your coverage is what tells you a locale is complete.
<domain>/<path>The domain is a capability’s name — the same segment that is the install argument, the migration namespace, the table prefix and the error-code domain.
The path is that capability’s own name for the string: lowercase, _ inside a word, . between levels.
auth/sign_in.title
email/magic_link.expiry.one
payments/pricing.buy.label
app/nav.settingsA tag is at most 64 characters, a key at most 129.
Your keys go under your own domain. The composer refuses a capability declaring keys under somebody else’s, naming both.
Plurals
The trailing segment is a plural category, and the translator picks it:
c.var.t.plural("email/magic_link.expiry", 15);The count is supplied for you — you do not interpolate it into the key and you do not pass it twice.
Which categories a language has is a property of the language rather than a choice. English has two. Several Slavic languages have four. Write the categories your language actually uses, not the two English happens to need.
Ship the least specific tag that is true
es, not es-ES. The catalog locale falls back and the formatting locale does not, so a regional reader already gets regional dates from a plain catalog.
Write a regional catalog only when the words genuinely differ.
Declare what language the file is in
If you run a prose or spelling check over your repository — and this kit does — a catalog written in another language is prose that is supposed not to be English.
A path list would answer that and is exactly what such a check must not grow. So the file says what it is, in its own head:
// LOCALE es — an unreviewed first pass. Not American English by design.Two facts in one line, and both are load-bearing. The locale it is written in, and that the copy is an unreviewed first pass — which is the honest state of the kit’s own Spanish today.
A file declaring English still gets read as English, so the marker cannot become a way to opt English prose out of the rule.
It exempts the file’s values, not the file
A catalog is not written in Spanish — its values are. Everything around them is a docblock, an import and an identifier: your own English prose, and exactly as subject to your rules as any other file’s.
So inside a marked file every quoted run counts as a value, whole sentences included. Every line outside the quotes is read exactly as it would be anywhere else.
That distinction is not fussiness. Skipping the whole file instead made the kit’s own error catalog a place where a twenty-seven-line English argument went unread — a gate that cannot fail on most of what it is looking at.
A run is one line’s worth: a quote that never closes on its line opens no value, so the inner lines of a multi-line template literal are read as prose.
The reader ships, so port nothing
import { localeDeclared, valueSpans } from "@pithy-sh/core/src/i18n/localeMarker";Both are pure string work — no filesystem, no platform import — so a check that reads its tree however it likes can call them.
The tag is handed back rather than a yes or a no, and that is deliberate: the line that decides is this translated is the one a hand-port gets backwards. The marker widens what counts as a value, and English widens nothing.
A check that reads the marker as skip this file passes silently, on the docblocks of every catalog the project ever adds.
No markup in a value
Ever.
An email’s subject and its plain-text part are precompiled with escaping off, so a value substituted there goes in verbatim. The HTML body escapes what it renders, and interpolated parameters are escaped with the sentence carrying them.
Emphasis belongs in the template, not in the string.
What not to translate
Error payload messages. They stay English permanently, and carry structured parameters beside them so a translating client renders its own string from the code — which is already the translation key, so there is no second identifier to keep in sync.
Operator diagnostics. Logs, CLI output, audit rows. A stack trace in a language the responder does not read is worse than useless.
A payload-carrying email template’s payload. The shell follows the job’s locale and your words are your words. Email in the reader’s language has the specific case.
Check it worked
- Every key resolves, and
pithy doctorreports no gaps - Plurals render the right category at 0, 1 and many
- The marker line is present and its spelling matches what your check reads
- No value carries markup