@pulsetechnologies/i18n
v0.8.1
Published
Shared language list, detection, formatting and language picker for every Pulse product.
Readme
@pulsetechnologies/i18n
The shared language layer for every Pulse product (PulseWork epic ENG-1, this is ENG-2).
- 18 languages:
en es zh-Hans zh-Hant hi ar fr pt-BR ru bn ja de id ur vi ko fil ht(Arabic and Urdu are right-to-left). - Choosing the language:
pickLocale({ user, cookie, workspace, browser })goes in that order, then falls back to English. Thepulse_langcookie covers all of.pulsetechnologies.ai, so a choice made in one product carries over to the others. - Matching:
matchLocalemaps real-world tags to the languages we offer:zh-TW→zh-Hant,pt-PT→pt-BR,es-MX→es,tl→fil. - Formatting:
createFormatters(locale, { timeZone })covers numbers, percent,money(minor, currency)for Pulse's minor-unit amounts, dates, times, relative times and lists. Haitian Creole borrows French formats because CLDR has no data for it. - i18next:
createI18n({ resources, locale })sets up English fallback, no catalog bleed betweenzh-Hansandzh-Hant, React-safe interpolation, and an optionalen-XApseudo-locale for catching text that was never extracted. - Catalog check:
missingKeys(en, target, locale)is plural-aware per language. Arabic needs six plural forms, Japanese one, and Spanish needs_many. Without it, Spanish users see English for counts in the millions. Thepulse-i18n checkcommand below runs it in CI. - React:
<LanguagePicker persist={saveToProfile} />is a button (flag + short code, so it never crowds a header) that opens a list showing every language with its flag, its English name and its name in its own script. It is keyboard- and screen-reader-accessible, lives in a portal and stays on screen (it flips up near the bottom and lines up with whichever edge fits), and keeps<html lang dir>in step, writes the cookie and callspersist(the ENG-3 profile save). Props:display="code" | "native" | "flag"(what the closed button shows),align,className/style(the button),menuClassName/menuStyle. Theme it with CSS variables on the button or an ancestor:--pulse-lang-fg,--pulse-lang-border,--pulse-lang-trigger-bg,--pulse-lang-menu-bg,--pulse-lang-menu-fg,--pulse-lang-menu-border,--pulse-lang-menu-active,--pulse-lang-menu-muted. Flags are bundled inline (about 12 KB, from the MIT-licensedcountry-flag-icons), because flag emoji do not render on Windows.<LanguageSelect>(a plain native<select>, no flags),<LanguageMenu>anduseLanguage()are the lower-level pieces.
import { createI18n, pickLocale } from "@pulsetechnologies/i18n";
import { LanguagePicker } from "@pulsetechnologies/i18n/react";
import { I18nextProvider, initReactI18next } from "react-i18next";
const { locale } = pickLocale({ user: me?.language, cookie: document.cookie, workspace: org.language, browser: navigator.languages });
const i18n = await createI18n({ resources, locale, use: [initReactI18next], pseudo: import.meta.env.DEV });
// <I18nextProvider i18n={i18n}> … <LanguagePicker persist={(c) => api.patch("/me", { language: c })} />The core entry has no DOM or React dependency, so it runs on servers (for emails and invoices) and in React Native. The React entry is for the web; React Native gets its own picker in ENG-19.
Develop
npm ci
npm test # vitest (the picker tests use jsdom)
npm run demo # http://localhost:5480, a sample invoice in 5 languages + pseudoTranslating a product: the pulse-i18n CLI (ENG-4)
Each product keeps its English catalogs at <dir>/en/<namespace>.json and commits the other languages next to them. Add a pulse-i18n.config.json at the product's root:
{
"dir": "src/locales",
"locales": ["es", "fr", "zh-Hans"],
"glossary": ["PulseVoice", "PiP", "Pulse"],
"review": ["legal.", "billing:", "e911."],
"context": "PulseVoice is a business phone system for companies and their staff."
}localesis the set of languages the product ships. Leave it out to ship all 18.glossarylists terms that are never translated, and checks that they come back unchanged.reviewlists key prefixes ("ns:key"or"key") whose translations a person must sign off: legal, invoice and 911 text.
Commands:
| Command | What it does |
|---|---|
| npx pulse-i18n check | The CI gate. Exits 1 when, for any language, a string is missing, a translation was made from English that has since changed, a key no longer exists in English, or a {{placeholder}}, $t() or <0> tag, or a glossary term, was dropped or invented. Plural forms are checked per language: Arabic needs 6, Japanese 1, and Spanish, French and Portuguese need _many. Pending reviews show as warnings. |
| npx pulse-i18n translate [--locale es,fr] [--batch 60] [--parallel 4] | Fills exactly what check flags, translating up to --parallel languages at once (default 4: about a quarter of the one-at-a-time time), using Claude Opus 5.5 (structured JSON output, fallbacks: "default", a cached prompt prefix). It validates every answer, retries once naming the problem, and reports anything still wrong without writing it. Needs ANTHROPIC_API_KEY (or ant auth login) and npm i -D @anthropic-ai/sdk. |
| npx pulse-i18n extract <files…> [--write] [--project tsconfig.json] | Codemod: moves a React/TSX app's English into the catalog. JSX text (joined with text-typed {values} into one message), UI attributes, setErr/notify/confirm-style calls, and sentences with simple inline markup (as <Trans>) become t() calls, with the exact rendered English as the catalog value, so English output can't change. Uses the type checker so a React element is never folded into a string. Computed values with no English in them (list.join(", "), dates) and "Hi " + name chains join the message; <code> content stays a value. Add your own message helpers with "calls": { "run": [1] } (callee → argument positions). Add your components' own text props with "attributes": ["hint", "cta"]. Text in JSX passed as a prop (foot={<Link>Back</Link>}) converts too. With "objectProps": true (or a list of extra names) tables of labels convert as well: label: "All work" becomes t() inside a function, and a getter at module level so it is translated when read; only for apps whose label tables are UI, not data. Re-runs reuse the name t was imported under. Reports what it leaves for a person: module-level constants, data-table labels, values that hold English (plurals), nested markup. Needs "extract": { "import": "src/i18n.ts" } in the config and typescript installed. Dry run unless --write. |
| npx pulse-i18n approve <locale> [prefix] | Marks reviewed translations as approved. |
translate keeps <dir>/.pulse-i18n-lock.json (commit it). It records the English each translation was made from, so:
- when English changes, that string is re-translated;
- a translation someone edited by hand is never overwritten;
- translations that existed before the tool are adopted as they are.
A hand edit that breaks a placeholder is redone, because it would fail at runtime.
Product CI:
- run: npx pulse-i18n checkRelease
The package is published publicly to npm as @pulsetechnologies/i18n. To release:
- Bump
versioninpackage.jsonand merge. - Publish a GitHub release tagged
v<version>.
The Publish workflow checks that the tag matches package.json, runs typecheck, tests and build (prepublishOnly), and publishes through npm trusted publishing (OIDC), so no npm token is stored anywhere. npm is retiring token publishing (2FA-bypass tokens stop publishing directly around January 2027).
The very first version (0.1.0) is published by hand, because npm only lets a trusted publisher be configured on a package that already exists. After that, set up npmjs.com → the package → Settings → Trusted publishing → GitHub Actions: pulsetechnologies-ai / pulse-i18n / publish.yml. Then set Publishing access to "Require two-factor authentication and disallow tokens".
Messages: emails, SMS and hosted pages (server side)
For text a server sends to a person (0.8.0). No DOM, no i18next: the same catalogs the apps use.
import { loadMessages } from "@pulsetechnologies/i18n/server";
import { htmlLangAttrs, smsInfo } from "@pulsetechnologies/i18n";
const messages = loadMessages(new URL("./locales", import.meta.url).pathname); // <dir>/<language>/common.json, once at start-up
// Whose language? override > the recipient's own > their organization's > the sending partner's > English.
const { locale, dir, intl, source } = messages.forRecipient({ recipient: user.language, group: org.locale });
const tr = messages.translator(locale);
const subject = tr("invite.subject", { name }); // {{vars}}, plural by `count`, falls back to English then the key
const html = `<html ${htmlLangAttrs(locale)}>…</html>`; // lang + dir (Arabic and Urdu are right-to-left)
const sms = tr("overdue.sms", { amount });
if (smsInfo(sms).segments > 2) { /* UCS-2 scripts cost several times the segments of English */ }A language without a catalog in the build is skipped (never English text tagged as that language). Strings a customer can edit in the database, text registered with carriers (SMS opt-in, STOP/HELP) and legal text are not translated by code: keep them English until they are reviewed.
The core (@pulsetechnologies/i18n) and @pulsetechnologies/i18n/server also load with require() (0.8.1: a CommonJS build in dist-cjs), for CommonJS servers such as NestJS. /react and /tools are ESM only.
