Add a translatable string
This page is for you if you are writing code that shows words to a reader. Every such word has to reach the translators, and the repository refuses to build if one does not.
The short version
Section titled “The short version”Wrap the string, name your text domain, then run make pot and commit
the template it regenerates. That is the whole contract. The rest of
this page explains what to wrap and the few places where wrapping needs
care.
The functions
Section titled “The functions”Everything a reader sees in AlphOne is React, so every string lives in
TypeScript. Import the gettext functions from @alphone/frontend-sdk,
never from anywhere else, so the whole application shares one
translation runtime.
import { __, _x, formatNumber, sprintf } from '@alphone/frontend-sdk'
__('Add contact', 'alphone')sprintf(__('A name runs to %(max)s characters at most.', 'alphone'), { max: formatNumber(max),})_x('Disabled', 'user status', 'alphone')Use __ for ordinary text. Use sprintf around it when a value goes
inside. A placeholder is named, like %(max)s, so a translator can move
it to wherever their language needs it. Every placeholder takes text,
so a number goes in already written by formatNumber, and a date by
formatDate, the same way WordPress hands number_format_i18n to its
strings. That is how 1234 reads as 1.234 whatever the language. A test
fails when a message holds a bare number placeholder such as %d. Use
_x when one English word means two different things, which is covered
below. The kit also exports _n for wording that changes with a count.
Name your own domain
Section titled “Name your own domain”The last argument is always the text domain, and it is always a literal.
Core code under frontend/src and sdk/frontend names alphone. A
plugin names its own domain, which is alphone- plus its folder name,
so the fields plugin writes 'alphone-fields'. Each domain is its own
catalogue and its own translation project, so a string filed under the
wrong domain would ship in the wrong catalogue. A test reads every
source file and fails when a call names a domain the file does not own.
A new plugin declares its catalogue
Section titled “A new plugin declares its catalogue”A plugin that shows words ships its own catalogues and tells the host
where they are. The manifest carries one locale entry.
const catalogs = import.meta.glob<{ default: Catalog }>('./languages/*.json')
export const plugin: FrontendPlugin = { id: 'whatsapp', locale: { domain: 'alphone-whatsapp', load: globCatalogs(catalogs) },}The host loads the catalogue matching the reader’s language before the first render, so a plugin never waits for its own words.
Error messages have their own seam
Section titled “Error messages have their own seam”The server answers a failed request with a short reason code. A template turns that code into a sentence for the reader. A plugin declares its templates as a function on the manifest, and the function runs when an error arrives rather than when the file loads, so the catalogue the reader loaded is the one that answers.
export function errorTemplates(): Record<string, string> { return { message_content_required: __('Write something to send.', 'alphone-whatsapp'), upstream_failed: __('WhatsApp did not accept the message.', 'alphone-whatsapp'), }}A reason a server stores as data, such as the reason on each import row,
renders through reasonText from the SDK with its own template map. Keep
that map out of errorTemplates, so no plugin overwrites another plugin’s
codes.
Never wrap these
Section titled “Never wrap these”Class names, test ids, route paths, GraphQL field names, reason codes, and any status or type value sent to the server. Those are identifiers, not words, and translating one breaks the software.
Also leave alone the product names AlphOne and WhatsApp, and anything a person typed into the product. A custom field’s label reads as its author wrote it, in whatever language they wrote it, and no catalogue reaches it.
When one word means two things
Section titled “When one word means two things”English reuses words that other languages separate. Disabled says one thing about a user and another about a button, and a language whose words agree with the noun they describe has to know which one it is. Give each use a context and translators will see them as separate entries.
_x('Disabled', 'user status', 'alphone')_x('WhatsApp', 'admin section', 'alphone-whatsapp')Only a word standing alone needs this. A word inside a whole sentence carries its own meaning already.
The trap that costs an afternoon
Section titled “The trap that costs an afternoon”Calling a gettext function at the top level of a module runs it once, when the file is first loaded. That can happen before the catalogue arrives, so the text freezes in English and never changes again. Every test still passes, because tests run in English.
The fix is to read the string when it is used rather than when the file loads. The navigation items do this with a getter.
nav: [{ get label() { return _x('WhatsApp', 'admin section', 'alphone-whatsapp') }, to: '/whatsapp', icon: whatsappIcon,}],The getter costs nothing, so prefer it for anything imported eagerly.
What the gates check
Section titled “What the gates check”Run make pot after adding a string, and commit the templates it
regenerates. There is one template per domain.
A test rebuilds every template and compares it to the committed one byte
for byte, so a forgotten make pot fails the build. The domain test
above catches a string filed under a domain its file does not own. The
linter refuses bare text in the admin’s markup, so a string that never
met a gettext function cannot ship. Further tests check every committed
translation, so an entry the template no longer names, a placeholder the
translation renamed, or a translation that fails to render all fail the
build. Nothing here depends on a human remembering.
If you also changed a translation, run make catalogs too. Compiled
catalogues are committed, and they are compared the same way.