| navTitle | Internationalisation | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| meta |
|
The platform UI is translatable. This page covers where the strings live, how a locale is chosen at runtime, and what to do when you add a string or a language.
| Side | Library | Interpolation syntax |
|---|---|---|
| Frontend | vue-i18n |
{name} |
| Backend | fastify-i18n (wrapping node-polyglot) |
%{name} |
The two interpolation syntaxes are different. This is a property of the underlying libraries, not a choice — copying a message from one side to the other means rewriting its placeholders.
locales/<locale>/common.json server-side messages
frontend/src/locales/<locale>.json browser-side messages
Locale codes are BCP 47 tags, so
zh-TW rather than zh_TW or tw.
The list of locales the platform ships is declared twice, because the frontend is bundled separately from the server:
forge/i18n/locales.js—SUPPORTED_LOCALESfrontend/src/i18n.js—SUPPORTED_LOCALES
test/unit/forge/i18n/locales_spec.js asserts the two agree, and that every
locale defines exactly the same keys as en. A missing translation is a test
failure rather than something that silently renders English in production.
Keys are hierarchical namespaces grouping semantically related messages:
{
"common": {
"actions": { "login": "Login" },
"errors": { "requiredField": "Required field" }
},
"auth": {
"login": {
"forgotPassword": "Forgot your password?",
"errors": { "loginFailed": "Login failed" }
}
}
}Put a message under common when more than one page uses it, and under a
page-specific namespace otherwise. Keys are sorted alphabetically within each
object — this keeps diffs readable when locales are edited in parallel.
In a template:
<label>{{ $t('common.fields.password') }}</label>In component code — both Options and Composition API, since the plugin is
registered with globalInjection:
this.errors.general = this.$t('auth.login.errors.loginFailed')When a message contains markup — a link inside a sentence — do not split it
into fragments. Word order differs between languages, and fragments cannot be
reordered. Use <i18n-t> with a named slot so the translator controls where the
markup lands:
<i18n-t keypath="auth.signUp.tcs" tag="span" scope="global">
<template #termsLink>
<a :href="url">{{ $t('auth.signUp.tcsLink') }}</a>
</template>
</i18n-t>{ "tcs": "I accept the {termsLink}" }scope="global" is required in components that do not call useI18n().
Messages read inside data() are a trap: prefer a computed, so the value is
re-evaluated when the locale changes rather than frozen at construction.
In order of precedence:
- The user's stored preference —
User.language, set on the account settings page - The request or browser locale —
Accept-Languageon the server,navigator.languagein the browser en
Steps 1 and 2 exist separately because the login and sign-up pages render before there is a session, so there is no stored preference to read. The browser locale is what makes those pages translatable at all.
resolveLocale() in frontend/src/i18n.js narrows an arbitrary tag onto one we
ship — zh-Hant-TW and zh both resolve to zh-TW — so an unrecognised tag
falls back rather than rendering raw keys.
The server needs a little help to match that. fastify-i18n narrows a regional
tag onto its base language, so en-GB finds en, but it does not widen a
script-qualified tag onto a regional one: zh-Hant-TW would fall back to
English rather than finding zh-TW. Chrome reports exactly that tag for
Traditional Chinese on some platforms, so forge/i18n/locales.js declares
LOCALE_ALIASES mapping those tags onto the canonical locale. Add an entry
there if a locale you add has script-qualified variants in the wild.
A user with no stored preference keeps whatever the browser negotiated. Signing in must not quietly reset someone to English.
Taking de as the example:
locales/de/common.json— copylocales/en/common.jsonand translatefrontend/src/locales/de.json— copyfrontend/src/locales/en.jsonand translate- Add
'de'toSUPPORTED_LOCALESinforge/i18n/locales.js - Add
{ value: 'de', label: 'Deutsch' }toSUPPORTED_LOCALESinfrontend/src/i18n.js, and import the file intomessages - Run
npm run test:unit:forge— the parity test will name any key you missed
Write the label in the language itself. Someone looking for their own language
should not have to read English to find it.
Add the key to every locale file, not just en. The parity test fails
otherwise. If you cannot translate it, that is a signal the locale needs a
maintainer for that language — not a reason to skip the key.
API error strings are not translated. Some of them are part of the de-facto
contract: frontend/src/pages/account/Create.vue branches on
err.response.data.error === 'user registration not enabled', and a unit test
matches the same literal. Translating those strings would break both.
Those responses already carry a stable code field —
user_registration_unavailable in that example — which is what callers should
branch on. Migrating consumers to code, and only then translating the
human-readable error text, is a separate piece of work.
The server-side plumbing is registered and request.i18n.t() is available, so
that work does not need to start from nothing. The natural first consumer is
forge/postoffice/templates/, where the output is read by a person and parsed
by nobody.
- Pluralisation. Both libraries support it; no message needs it yet. Reach for the library's own plural syntax rather than branching in a component.
- Date, time, and number formatting. Still locale-independent.
- Right-to-left layouts. No RTL locale ships yet; the CSS has not been audited for it.