Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
176 changes: 176 additions & 0 deletions docs/contribute/i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
---
navTitle: Internationalisation
meta:
description: How translatable strings are handled in the FlowFuse platform, and how to add or extend a locale.
tags:
- flowfuse
- i18n
- internationalisation
- localisation
- contributing
---

# Internationalisation

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.

## Libraries

| Side | Library | Interpolation syntax |
|---|---|---|
| Frontend | [`vue-i18n`](https://vue-i18n.intlify.dev/) | `{name}` |
| Backend | [`fastify-i18n`](https://github.com/Vanilla-IceCream/fastify-i18n) (wrapping [`node-polyglot`](https://github.com/airbnb/polyglot.js)) | `%{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.

## Where strings live

```
locales/<locale>/common.json server-side messages
frontend/src/locales/<locale>.json browser-side messages
```

Locale codes are [BCP 47](https://www.rfc-editor.org/info/bcp47) 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_LOCALES`
- `frontend/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.

## Key naming

Keys are hierarchical namespaces grouping semantically related messages:

```json
{
"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.

## Using a message

In a template:

```html
<label>{{ $t('common.fields.password') }}</label>
```

In component code — both Options and Composition API, since the plugin is
registered with `globalInjection`:

```js
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:

```html
<i18n-t keypath="auth.signUp.tcs" tag="span" scope="global">
<template #termsLink>
<a :href="url">{{ $t('auth.signUp.tcsLink') }}</a>
</template>
</i18n-t>
```

```json
{ "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.

## How a locale is chosen

In order of precedence:

1. The user's stored preference — `User.language`, set on the account settings page
2. The request or browser locale — `Accept-Language` on the server, `navigator.language` in the browser
3. `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.

## Adding a locale

Taking `de` as the example:

1. `locales/de/common.json` — copy `locales/en/common.json` and translate
2. `frontend/src/locales/de.json` — copy `frontend/src/locales/en.json` and translate
3. Add `'de'` to `SUPPORTED_LOCALES` in `forge/i18n/locales.js`
4. Add `{ value: 'de', label: 'Deutsch' }` to `SUPPORTED_LOCALES` in `frontend/src/i18n.js`, and import the file into `messages`
5. 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.

## Adding a string

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.

## Deliberately out of scope

**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.

## Not yet handled

- **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.
28 changes: 28 additions & 0 deletions forge/db/migrations/20260827-01-add-language-to-users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Let a user pick the language the platform is presented in.
*
* language - a BCP 47 locale tag, e.g. `zh-TW`. Null means the user has
* expressed no preference, in which case the platform negotiates a
* locale from the request (`Accept-Language` on the server,
* `navigator.language` in the browser) and falls back to English.
* Null is the default so existing users keep the behaviour they
* have today.
*
* This is stored server-side rather than kept in the browser — unlike the theme
* preference, which is local-only — because content the platform generates
* outside a browser session needs it too, most obviously the emails rendered in
* forge/postoffice/templates.
*/

const { DataTypes } = require('sequelize')

module.exports = {
up: async (context) => {
await context.addColumn('Users', 'language', {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null
})
},
down: async (context) => {}
}
3 changes: 3 additions & 0 deletions forge/db/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ module.exports = {
}
},
tcs_accepted: { type: DataTypes.DATE, allowNull: true },
// A BCP 47 locale tag. Null means 'no preference' — the platform then
// negotiates one from the request. See forge/i18n.
language: { type: DataTypes.STRING, allowNull: true, defaultValue: null },
suspended: {
type: DataTypes.BOOLEAN,
defaultValue: false
Expand Down
4 changes: 4 additions & 0 deletions forge/db/views/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = function (app) {
mfa_enabled: { type: 'boolean' },
free_trial_available: { type: 'boolean' },
tcs_accepted: { type: 'string' },
language: { type: 'string' },
password_expired: { type: 'boolean' },
pendingEmailChange: { type: 'boolean' },
SSOGroups: { type: 'array' }
Expand All @@ -30,6 +31,9 @@ module.exports = function (app) {
// Only include the tcs_accepted date if 'tcs-required' is enabled
result.tcs_accepted = user.tcs_accepted
}
if (user.language) {
result.language = user.language
}
result.email_verified = user.email_verified
if (user.defaultTeamId) {
result.defaultTeam = app.db.models.Team.encodeHashid(user.defaultTeamId)
Expand Down
3 changes: 3 additions & 0 deletions forge/forge.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const containers = require('./containers')
const db = require('./db')
const ee = require('./ee')
const housekeeper = require('./housekeeper')
const i18n = require('./i18n')
const { generatePassword } = require('./lib/userTeam')
const license = require('./licensing')
const notifications = require('./notifications')
Expand Down Expand Up @@ -196,6 +197,8 @@ module.exports = async (options = {}) => {
await server.register(caches)
// DB : the database connection/models/views/controllers
await server.register(db)
// Internationalisation : request-scoped translation
await server.register(i18n)
// Settings
await server.register(settings)
// License
Expand Down
30 changes: 30 additions & 0 deletions forge/i18n/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const fastifyI18n = require('fastify-i18n')
const fp = require('fastify-plugin')

const { FALLBACK_LOCALE, messages } = require('./locales')

/**
* Server-side internationalisation.
*
* Decorates the request with `request.i18n`, whose `t()` resolves a key against
* the locale negotiated from the request's `Accept-Language` header, falling
* back to English. `fastify-i18n` narrows a regional tag onto its base language
* — `en-GB` finds `en` — but does not widen a script-qualified tag onto a
* regional one, which is why `forge/i18n/locales.js` declares LOCALE_ALIASES so
* `zh-Hant-TW` resolves to `zh-TW` instead of falling back to English.
*
* Note on scope: existing API error strings are deliberately NOT routed through
* this. Several of them are part of the de-facto contract — the frontend
* branches on `err.response.data.error === 'user registration not enabled'`
* (frontend/src/pages/account/Create.vue) and a unit test matches the same
* literal — so translating them would be a breaking change. Those responses
* already carry a stable `code` field, which is the right thing for callers to
* branch on; migrating consumers to it is a separate piece of work. See
* docs/contribute/i18n.md.
*/
module.exports = fp(async function (app, opts) {
await app.register(fastifyI18n, {
fallbackLocale: FALLBACK_LOCALE,
messages
})
}, { name: 'app.i18n' })
55 changes: 55 additions & 0 deletions forge/i18n/locales.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* The locales the platform ships translations for.
*
* Shared between the i18n plugin, the API schemas that validate a user's
* language preference, and the tests that assert locale files stay in step.
* The frontend keeps its own copy of this list in `frontend/src/i18n.js`
* because it is bundled separately; `test/unit/forge/i18n/locales_spec.js`
* asserts the two do not drift apart.
*/

const en = require('../../locales/en/common.json')
const zhTW = require('../../locales/zh-TW/common.json')

const FALLBACK_LOCALE = 'en'

/**
* Canonical locale tags. These are what a user's `language` preference may be
* set to, and what the account settings UI offers.
*/
const SUPPORTED_LOCALES = ['en', 'zh-TW']

/**
* Message catalogues, keyed by canonical locale.
*/
const catalogues = {
en,
'zh-TW': zhTW
}

/**
* Extra tags browsers send that should resolve to a locale we ship rather than
* falling back to English.
*
* `fastify-i18n` narrows a regional tag onto its base language — `ja-JP` finds
* `ja` — but it does not widen a script-qualified tag onto a regional one, so
* `zh-Hant-TW` would not find `zh-TW` on its own. Chrome reports exactly that
* tag for Traditional Chinese on some platforms, so it is worth handling.
*/
const LOCALE_ALIASES = {
'zh-Hant': 'zh-TW',
'zh-Hant-TW': 'zh-TW'
}

const messages = { ...catalogues }
for (const [alias, canonical] of Object.entries(LOCALE_ALIASES)) {
messages[alias] = catalogues[canonical]
}

module.exports = {
FALLBACK_LOCALE,
SUPPORTED_LOCALES,
LOCALE_ALIASES,
catalogues,
messages
}
6 changes: 6 additions & 0 deletions forge/routes/api/shared/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ module.exports = {
if (request.body.tcs_accepted) {
user.tcs_accepted = new Date()
}
// Unlike the fields above, null is meaningful for `language` — it
// clears the preference so the platform negotiates a locale from
// the request instead. So test for presence, not truthiness.
if (Object.hasOwn(request.body, 'language')) {
user.language = request.body.language || null
}
if (isAdmin) {
// Settings only an admin can modify
if (request.body.email_verified !== undefined) {
Expand Down
7 changes: 6 additions & 1 deletion forge/routes/api/user.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { SUPPORTED_LOCALES } = require('../../i18n/locales')

const sharedUser = require('./shared/users')
const UserInvitations = require('./userInvitations')
const UserNotifications = require('./userNotifications')
Expand Down Expand Up @@ -163,7 +165,10 @@ module.exports = async function (app) {
username: { type: 'string' },
email: { type: 'string' },
tcs_accepted: { type: 'boolean' },
defaultTeam: { type: 'string' }
defaultTeam: { type: 'string' },
// null clears the preference, letting the platform
// negotiate a locale from the request instead
language: { type: ['string', 'null'], enum: [...SUPPORTED_LOCALES, null] }
}
},
response: {
Expand Down
Loading