Files
zeekay de18ed418c @hanzo/i18n: a catalog in parts, loadCatalog required, offered locales may ship partial
A whole catalog is every key of every reader, now some 12,900 of them,
and a page draws a few hundred. I18nProvider takes `scope`, the part of
the site shown: `loadCatalog(code, scope)` is asked for it with the
language and for each new part once on a move, its answer joined to
what is shown.

`loadCatalog` is required and the bundled catalogs are their own entry,
`@hanzo/i18n/bundled`: referenced from the provider's default, they put
78 catalog chunks into every host's build whether it loaded them or not
(7.8 MB of hanzo.ai's export today, some 130 MB at the new size).

review.json's `{ "partial" }` with no `why` is an offered locale a run
has not finished: still offered, a key it lacks reads English.

tools/translate writes each batch as it answers, the locale file and one
provenance run per locale per run, so a stopped run keeps what came back:
on a local model a locale is hours of work.

Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
2026-10-30 01:18:49 -07:00

9.3 KiB
Raw Permalink Blame History

@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 { bundled } from '@hanzo/i18n/bundled'
import '@hanzo/i18n/rtl.css'

<I18nProvider loadCatalog={bundled} 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.
  • Partial. A locale catalogs/review.json marks partial ships before a run has reached every key; a key it lacks reads English. { "why", "partial" } is a review locale; { "partial" } alone is an offered one, still offered.
  • Where the words come from. loadCatalog is required. bundled (@hanzo/i18n/bundled) loads the catalog this package ships, one lazy chunk per locale, whole. A whole catalog is every key of every reader, so a host that serves its catalogs in parts passes its own loadCatalog(code, scope) and scope (the part of the site shown): the provider asks for that part with the language, and for each new part once on a move, adding what it answers to what is shown. Such a host never imports ./bundled, so its build carries no catalog chunk.
  • 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.