gipity add i18n makes a web app multi-language: a language picker, localStorage persistence, automatic RTL (<html dir>), and plural/translation lookup. It installs into src/packages/i18n/ and transparently upgrades the app's existing copy calls — no edits to main.js.
The seam: keep copy in strings.js
Web starter apps (web-simple and friends) keep all user-facing text in src/js/strings.js and read it with t('key'), importing the reader from the @gipity/i18n specifier:
import { t, tn, getLang } from '@gipity/i18n';
el.textContent = t('welcome');
el.textContent = t('greeting', { name: 'Sam' }); // "Hello, Sam!" — {placeholder} interpolation
el.textContent = tn('items', cart.length); // plural-aware: items_one / items_other
// src/js/strings.js — the one place copy lives
export const strings = {
welcome: 'Welcome',
greeting: 'Hello, {name}!',
items_one: '{count} item',
items_other: '{count} items',
};
Out of the box @gipity/i18n resolves to a tiny monolingual reader (src/js/i18n.js): t() just returns the string with interpolation. No picker, no weight. So even a single-language app should route copy through t() — it's good structure, and it's what makes step 2 free.
Add multi-language
gipity add i18n
The kit's install overrides the @gipity/i18n import to its full runtime and adds an @app/strings alias pointing at your src/js/strings.js. Every t('key') call site upgrades at once — translation lookup, the language <select> (auto-mounted once translations exist), persistence, and RTL. Then:
"translate strings to Spanish and Japanese"
Translations land in src/packages/i18n/translations.js, mirroring the keys in strings.js. English stays the base in strings.js and never appears there.
export const translations = {
es: { welcome: 'Bienvenido', items_one: '{count} artículo', items_other: '{count} artículos' },
ja: { welcome: 'ようこそ', items_other: '{count}個' },
};
The runtime API
import { t, tn, getLang, setLang, availableLangs, isRtl } from '@gipity/i18n';
setLang('es'); // switch + persist; getLang()/<html lang|dir>/listeners all updated synchronously
availableLangs(); // ['en', ...languages present in translations.js]
The picker is a <select> auto-mounted to <body> once at least one translation exists — nothing to wire. Style it via the .lang-picker class (the web templates already position it top-right). To place it yourself, mountLangPicker(i18n, parentEl).
Re-rendering on language change
setLang() (and the picker) fires an i18n:changed event on document, synchronously — by the time it returns, getLang() and <html lang>/<html dir> are already updated. Re-pull your text in a render() you wire to it — the web starter's main.js already does:
function render() { /* your t('key') → DOM assignments */ }
document.addEventListener('i18n:changed', render);
render();
Notes
- Add it early. Routing copy through
t()as you build the UI is what makes the install zero-edit. Bolting it onto an app with hard-coded strings means moving that copy intostrings.jsfirst. - Plurals use
_one/_other(plus_zero/_two/_few/_manywhere a language needs them) perIntl.PluralRules. - RTL scripts (
ar,he,fa,ur,yi) flip layout automatically. - Default language is predictable. First load defaults to English (the base copy in
strings.js); the kit only auto-switches to a detected browser language when the app actually ships that language (i.e. it's intranslations.js). A guest on anarbrowser visiting an English-only app stays English (no surprise RTL flip) — the always-visible picker lets them switch if the guess was wrong, and that explicit choice persists inlocalStorage. To make a different language the default instead of English, set the owner's language as the first/base copy. - Number/date/currency aren't wrapped —
Intl.*is a one-liner withgetLang():new Intl.NumberFormat(getLang(), { style: 'currency', currency: 'USD' }).format(99.5). - Web only. For server-side / function copy, format with
Intl.*directly.