Files
zeekay 80e746ad96 @hanzo/i18n 0.5.4: Say and english, a plain module's t
A module that words a sentence outside React takes `t: Say = english`
last. The type and its English default were copied into @hanzo/app and
are about to be needed in @hanzo/iam, @hanzo/plans and @hanzo/usage as
the app's lib moves to the packages that own it; one copy lives here.
2026-10-22 19:46:07 -07:00
..

@hanzo/i18n

Hanzo's localization in one package: a registry of 79 locales, the negotiation every surface shares, a React provider and language picker, right-to-left layout, and the interface catalogs, each loaded only when chosen.

pnpm add @hanzo/i18n

React

import { I18nProvider, LanguagePicker, useI18n } from '@hanzo/i18n/react'
import '@hanzo/i18n/rtl.css'

<I18nProvider userId={subject} account={{ base: 'https://hanzo.id', token }}>
  <LanguagePicker />
  <App />
</I18nProvider>

function Title({ name }: { name: string }) {
  const { t } = useI18n()
  return <h1>{t('Welcome back, {name}', { name })}</h1>
}

t('English source', { values }) translates authored interface copy; a message a catalog lacks renders its English. Never pass user content, organization names, code or API responses through it. Rendering never calls a model.

  • Choice. Saved per account in this browser, with a separate signed-out choice; other tabs follow. With account, it is also read from and written to IAM's /v1/iam/preferences (only locale), so it follows the person across devices, and the account's choice is kept in this browser so the next load opens in it. A failed save keeps the device choice and reports it; a failed catalog load keeps the current language and offers a retry.
  • First visit. Signed out, the browser's languages pick one; signed in, initialLocale (English). A server prerender is English and hydrates; with bootScript in the head, <html lang dir> is right before the first paint.
  • Review. Locales in catalogs/review.json are machine translations waiting for a native reader. They load by code, so a person who chose one keeps it, but the picker does not offer them and a browser language does not select them. <LanguagePicker review /> offers them.
  • Right-to-left. Every locale in Arabic or Hebrew script (Arabic and its regional variants, Persian, Dari, Hebrew, Urdu, Pashto, Central Kurdish, Sindhi, Uyghur, Punjabi in Shahmukhi) sets <html lang dir="rtl">. Lay out with logical properties (margin-inline-start, text-align: start). rtl.css selects on :dir(rtl), so it holds on a right-to-left page and inside any dir="rtl" region: it keeps code, URLs, email inputs and [data-i18n-ltr] left-to-right, and mirrors [data-i18n-mirror] icons. Language names are in their own scripts, never flags.
  • Scripts. Load a Noto family per script you serve. Urdu and Punjabi (Pakistan) read best in Nastaliq, which needs line-height of about 2.2; Noto Naskh Arabic is the compact choice. Persian spells with the zero-width non-joiner (U+200C): do not strip it. Burmese, Khmer, Lao and Thai put no spaces between words, so keep word-break: normal and let the browser's dictionary break lines; their stacked marks want line-height of 1.7 or more. Georgian has no letter case: do not text-transform: uppercase it.

Before the first paint

A static export arrives in English. Put bootScript() first in the head and the page opens in the reader's direction, before any bundle runs:

import { bootScript } from '@hanzo/i18n/boot'
// the root layout: first in <head>
<html lang="en-US" suppressHydrationWarning><head><script dangerouslySetInnerHTML={{ __html: bootScript() }} />…</head>…</html>

It sets <html lang dir> to what the provider last showed in this browser, or else the browser's languages negotiated as resolve does, and touches nothing else. About 1.5 KB, no React, safe in a server component.

Send sentences, not fragments

A string is translated alone. A sentence split around a link ("By continuing, you agree to Hanzo's " + <a>Terms</a>) cannot be put in another language's order: Arabic places the owner after the noun, inside the link, so the fragment's translation drops "Hanzo". Pass the whole sentence as one string with the link as an argument. Give t an element and it returns a node, the sentence in the reader's order with the element where the translation puts it:

t('By continuing, you agree to the {terms}.', {
  terms: <a href="/terms">{t("Hanzo's Terms of Service")}</a>,
})

A figure is an argument too, never concatenated: t('{n} tokens a second', { n }).

A server component: <T>

<T> is t as an element, so a server component (which cannot call a hook) hands its English to the client to translate:

import { T } from '@hanzo/i18n/react'

<T>Plans that grow with you.</T>
<T values={{ n: <strong>{count}</strong> }}>{'{n} tasks left'}</T>

The children are the key, exactly as the compiler hands them over: JSX text written over several lines is one line joined by single spaces, entities decoded. A sentence with arguments is a string in braces, since {n} in JSX text would be an expression. <T>Hello {name}</T> gives T a list, not a key; write values instead.

Figures, money, dates and lists

format is Intl at the reader's locale. Options are Intl's, passed through, so in English every helper writes what toLocaleString('en-US', options) writes:

const { t, format } = useI18n()
format.number(1234567)                        // 1,234,567 · ١٬٢٣٤٬٥٦٧ in ar-EG
format.money(1234.5, 'USD')                   // $1,234.50
format.percent(0.929, { maximumFractionDigits: 1 })  // 92.9%
format.date(iso, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })  // Oct 22, 2026
format.relative(updatedAt)                    // 3 minutes ago · { numeric: 'auto' } for yesterday
format.list(['Chat', 'Dev', 'Kai'])           // Chat, Dev, and Kai
t('{n} tokens a second', { n: format.number(rate, { maximumFractionDigits: 0 }) })

The locale decides its digits and calendar as Intl does: ar writes Latin digits, ar-EG and ar-SA Arabic-Indic, fa Persian digits and the solar calendar. A caller who needs otherwise says so in options (numberingSystem, calendar). relative picks the largest unit the span fills, from now (the clock by default), or the unit asked for. A moment Date cannot read throws a RangeError, never the words "Invalid Date". Outside React, format(locale) from @hanzo/i18n is the same.

Figures in right-to-left prose

In a right-to-left locale each argument is isolated: a string argument is wrapped in Unicode's first-strong isolate (isolate), an element in <bdi>. So 1,150–1,400 still reads 1,150 then 1,400, and ~43 tok/s does not become tok/s 43~. Everything format writes is isolated the same way, a list item by item, so Latin names in an Arabic list read right to left; handed to t, it is not isolated twice. A figure drawn outside any sentence and not from format needs the same: mark it data-i18n-ltr (on an inline element) or wrap it in <bdi>.

Copy written as data

A menu label written in a data file is translated where it is drawn. Mark it with phrase so the extractor finds it (it reads t(…), phrase(…) and <T>); phrase returns its argument:

import { phrase } from '@hanzo/i18n'
export const MENU = [{ label: phrase('Pricing'), href: '/pricing' }]
// …
<a href={item.href}>{t(item.label)}</a>

Everywhere else

import { locales, resolve, direction, translate } from '@hanzo/i18n'
import { negotiate, links, sitemap } from '@hanzo/i18n/server'
import { detect } from '@hanzo/i18n/native'

resolve(['zh-TW', 'en'])            // 'zh-Hant' — script decides, then region
resolve('es-CO')                    // 'es-419' — the variant that serves Colombia
negotiate(request.headers.get('accept-language'))
links('/pricing', { origin: 'https://hanzo.ai' })   // reciprocal hreflang set, x-default included
detect()                            // the device's language, negotiated the same way

Catalogs are also importable directly: @hanzo/i18n/catalogs/ar.json.

Locales

79 locales: am, ar, ar-EG, ar-LB, ar-MA, ar-SA, hy, as, az, bn, bho, my, ceb, ckb, zh-HK, zh-Hans, zh-Hant, da, fa-AF, nl, en-US, fil, fi, fr-CA, fr-FR, ka, de-DE, gu, ht, ha, he, hi-IN, id-ID, it-IT, ja-JP, jv, kn, kk, km, ko-KR, ku, ky, lo, ms, ml, mr, mn, ne, nb, or, om, ps, fa, pt-BR, pt-PT, pa, pa-PK, ru, sd, si, so, es-AR, es-419, es-MX, es-ES, su, sw, sv, tg, ta, te, th, tr, tk, uk, ur, ug, uz, vi. Those in catalogs/review.json wait for a native reader and are not offered until one clears them. Every translation is machine-made and validated (placeholders, protected names in Latin script, the locale's own script, the right Chinese); catalogs/provenance.json records the model and run behind each message.