How Bones

Internationalization

Writing a string that can be translated, translating it, and keeping right-to-left layouts working.

Write a string

Every string a user reads goes through t(), with its English inline. Get t from the hook or function for where the code runs:

WhereGet t with
shared-ui componentsuseT("ui")
web-app and desktopuseT("app")
web-staticuseT("site")
blog and docs serversawait getT(language, "blog") or "docs"
email-templatescreateT(language, emailResources(language), "email")

A one-liner uses its English as the key:

const t = useT("app");

<PageHeader title={t("Profile")} />;

A paragraph gets a named key, <namespace>.<area>.<role>, with the English as its default. Then fixing a typo doesn't drop its translations.

t(
  "app.orgUsage.intro",
  "Chatbot usage by member, per UTC month. When the organization or a member reaches a limit, the chatbot stops answering them until the next month.",
);

Put values in placeholders, not template strings: t("Only {{max}} allowed.", { max }). For markup inside a sentence, use Trans with a named key.

Call useT or getT in the file where the strings are. Extraction reads the namespace from that call, so a t passed in as a parameter lands its strings in the wrong catalog.

Backend errors

Mark any message a user will read with msg(), and throw it with userError:

throw userError("FORBIDDEN", msg("That role ranks above your own."));

The client shows it with useErrorText, which looks the English up in the errors catalog and falls back to it:

const errorText = useErrorText();

setError(errorText(error, t("Couldn't save that change.")));

Translate it

  1. Run yarn workspace i18n extract. The new key appears in i18n/locales/en, and empty in fr, es, and he.
  2. Translate it in i18n/locales/<language>/<namespace>.json, or leave it empty to show English.
  3. For a named key, run yarn workspace i18n stamp <language> <key> for each language you translated. It records the English the translation was made from.

Keep the terms consistent with the existing catalogs. French uses vous, Spanish uses tú, and Hebrew uses gender-neutral forms.

What CI checks

The Translation catalogs in sync with the source step in the Lint job runs four commands. Run them yourself before pushing:

CommandFails when
yarn workspace i18n extract:checkA t() or msg() isn't in the catalogs, or a removed key still is
yarn workspace i18n check:staleA named key's English changed since its translation was made
yarn workspace i18n lint:stringsInterface text skips t()
yarn workspace i18n testThe i18n workspace's own tests fail

A missing translation doesn't fail anything. It shows the English, by design.

To excuse text that isn't copy, such as a brand name or a path, put a comment on the line before it:

{
  /* i18next-instrument-ignore-next-line -- the product name */
}

Dates and numbers

Format through useLanguage(), not toLocaleString():

const { formatDate } = useLanguage();

It formats with the active language and the browser's region.

Right to left

Hebrew mirrors every layout. To keep that working:

  • Use logical CSS. margin-inline-start, padding-inline-end, inset-inline-start, and text-align: start, never left or right.
  • Wrap machine values in Mono. It keeps a path or ID in its own direction.
  • Mark text a person wrote. Chat messages, posts, and other user content get dir="auto", so English inside a Hebrew page keeps its punctuation in place.

Check a change in Hebrew: pick it from Storybook's Language toolbar, or open a public site under /il.