@tapestry-ui/i18n
v0.3.0
Published
A small i18n core for Preact apps — a typed catalog, per-key fallback, CLDR plurals, and a pseudo-locale that makes untranslated text visible. The app sets keys, the DOM resolves.
Maintainers
Readme
@tapestry-ui/i18n
A small i18n core for Preact apps. The app sets keys, the DOM resolves.
A utility, not a component — it renders nothing. Part of Tapestry UI, lifted from Brandskviða, which has run this shape across eleven locales since 2026-09.
npm i @tapestry-ui/i18n// en.ts — the source of truth. Keys name WHERE a string lives, never what it says.
export const en = {
'gaps.title': 'Intel gaps',
'gaps.toggle.open': '{n, plural, one {# open gap} other {# open gaps}}',
'greeting.partner': 'Evening, {who}.',
} as const;
export type MsgKey = keyof typeof en;// i18n.ts — the three app-specific facts, and nothing else
export const { translate, textOf, message, locale, setLocale } = createI18n({
source: en,
catalogs: { es },
registry: [{ code: 'en', endonym: 'English' }, { code: 'es', endonym: 'Español' }],
storageKey: 'my-app-locale',
sourceCode: 'en',
});<h2>{translate('gaps.title')}</h2>
<p>{translate('gaps.toggle.open', { n: 12 })}</p>locale is a signal, so setLocale('es') re-renders every visible string. No
reload, no provider threaded through the tree.
Three rules it exists to enforce
Per-key fallback. A locale file is Partial<Record<MsgKey, string>> on
purpose. Ship forty keys today and four hundred next month; anything absent
falls through to the source key by key, never to a blank string. An empty
stub is a valid locale.
Keys say WHERE, not what. tile.cash.title, not cashAndRunway. A key that
describes the text has to be renamed when the text changes, which nobody does.
Your data is not copy. A person's name, a property, something a user typed —
those are content. They must not be in the catalog, because a translator must
not be able to reach them. They arrive as {vars}.
The pseudo-locale is the point
setLocale('pseudo') generates a catalog from the source: accented look-alikes,
~35% padding, [brackets].
Unbracketed text on screen is a hardcoded string your extraction missed. Overflowing text is a layout that assumed English lengths.
A grep can only see literals it recognises. The pseudo-locale sees everything that reaches the screen, which is why it is the real meter.
It mangles the words inside a plural's branches but leaves the syntax alone —
other {…} has to survive, or the parser stops finding the branch. (That was a
real bug, found by the pseudo-locale catching itself.)
Plurals
{n, plural, one {# thing} other {# things}}, backed by Intl.PluralRules,
with # as the count. Any CLDR category is accepted, so a language needing
few and many writes them, and a language that never does never has to. A
missing category falls back to other.
Never build a plural by concatenation, and never write N item(s) — those are
English rules wearing a disguise.
Tokens your catalog must not contain
createI18n({ ..., tokens: { name: (id, tag) => NAMES[locale][id][tag ?? 'nom'] } }){name.jon} and {name.jon:gen} are handed to your resolver. This is how
Brandskviða transliterates Norse names into Cyrillic and declines them, without
the library ever learning what a Norse name is.
API
| | |
|---|---|
| createI18n(options) | the factory; everything it returns is typed to your keys |
| translate(key, vars?) | resolve now |
| textOf(msg) | resolve a Msg; a raw string passes through verbatim |
| message(key, vars?) | build a Msg for a call site that carries one |
| keyOf(msg) | compare keys, never rendered text |
| locale / setLocale | the signal, and the setter that persists + stamps <html lang> |
| pseudoize, resolveLocale, resolveMessage | pure, exported, and where the logic lives |
Locale resolution: ?lang= → saved → navigator.language → source.
Names read like English by house law: translate · textOf · message. Never a
lazy t().
License
MIT © Brandon Minton
