diff --git a/docs/contribute/i18n.md b/docs/contribute/i18n.md new file mode 100644 index 0000000000..97f849d23b --- /dev/null +++ b/docs/contribute/i18n.md @@ -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//common.json server-side messages +frontend/src/locales/.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 + +``` + +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 `` with a named slot so the translator controls where the +markup lands: + +```html + + + +``` + +```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. diff --git a/forge/db/migrations/20260827-01-add-language-to-users.js b/forge/db/migrations/20260827-01-add-language-to-users.js new file mode 100644 index 0000000000..d89ebdec2e --- /dev/null +++ b/forge/db/migrations/20260827-01-add-language-to-users.js @@ -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) => {} +} diff --git a/forge/db/models/User.js b/forge/db/models/User.js index 86f29680e5..a425813e59 100644 --- a/forge/db/models/User.js +++ b/forge/db/models/User.js @@ -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 diff --git a/forge/db/views/User.js b/forge/db/views/User.js index b03afbdb03..f141f6b3be 100644 --- a/forge/db/views/User.js +++ b/forge/db/views/User.js @@ -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' } @@ -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) diff --git a/forge/forge.js b/forge/forge.js index dbddbb1819..0c33dca46e 100644 --- a/forge/forge.js +++ b/forge/forge.js @@ -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') @@ -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 diff --git a/forge/i18n/index.js b/forge/i18n/index.js new file mode 100644 index 0000000000..dedfbeeafa --- /dev/null +++ b/forge/i18n/index.js @@ -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' }) diff --git a/forge/i18n/locales.js b/forge/i18n/locales.js new file mode 100644 index 0000000000..d10693aba6 --- /dev/null +++ b/forge/i18n/locales.js @@ -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 +} diff --git a/forge/routes/api/shared/users.js b/forge/routes/api/shared/users.js index 0e0f5920b1..60e75b17b5 100644 --- a/forge/routes/api/shared/users.js +++ b/forge/routes/api/shared/users.js @@ -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) { diff --git a/forge/routes/api/user.js b/forge/routes/api/user.js index 7105566f08..6d4a6f0a8b 100644 --- a/forge/routes/api/user.js +++ b/forge/routes/api/user.js @@ -1,3 +1,5 @@ +const { SUPPORTED_LOCALES } = require('../../i18n/locales') + const sharedUser = require('./shared/users') const UserInvitations = require('./userInvitations') const UserNotifications = require('./userNotifications') @@ -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: { diff --git a/frontend/src/api/team.ts b/frontend/src/api/team.ts index 01021fd1ee..213ca6d7ac 100644 --- a/frontend/src/api/team.ts +++ b/frontend/src/api/team.ts @@ -3,6 +3,7 @@ import product from '../services/product' import daysSince from '../utils/daysSince' import elapsedTime from '../utils/elapsedTime' import paginateUrl from '../utils/paginateUrl' +import { roleLabel } from '../utils/roleLabels.js' import { RoleNames, Roles } from '../utils/roles' import client from './client' @@ -19,9 +20,9 @@ import type { type RouterLink = { name: string, params: Record } -type TeamListItem = UserTeamList[number] & { link: RouterLink, roleName: string } +type TeamListItem = UserTeamList[number] & { link: RouterLink, roleName: string, roleLabel: string } -type InvitationView = Invitation & { roleName: string, createdSince: string, expires: string } +type InvitationView = Invitation & { roleName: string, roleLabel: string, createdSince: string, expires: string } type DeviceView = DeviceSummary & { lastSeenSince: string, instance?: DeviceSummary['application'] } @@ -30,7 +31,8 @@ const getTeams = async (): Promise<{ teams: TeamListItem[] }> => { const teams = res.data.teams.map((r): TeamListItem => ({ ...r, link: { name: 'team', params: { team_slug: r.slug } }, - roleName: RoleNames[r.role] + roleName: RoleNames[r.role], + roleLabel: roleLabel(r.role) })) return { ...res.data, teams } } @@ -263,6 +265,7 @@ const getTeamInvitations = (teamId: string): Promise<{ invitations: InvitationVi const invitations = res.data.invitations.map((r): InvitationView => ({ ...r, roleName: RoleNames[r.role || Roles.Member], + roleLabel: roleLabel(r.role || Roles.Member), createdSince: daysSince(r.createdAt), expires: elapsedTime(r.expiresAt, Date.now()) })) @@ -306,6 +309,7 @@ const resendTeamInvitation = (teamId: string, inviteId: string): Promise { r.createdSince = daysSince(r.createdAt) r.expires = elapsedTime(r.expiresAt, Date.now()) r.roleName = RoleNames[r.role || Roles.Member] + r.roleLabel = roleLabel(r.role || Roles.Member) return r }) return res.data diff --git a/frontend/src/components/CookieConsent.vue b/frontend/src/components/CookieConsent.vue index b3f0c3f251..48e102e67a 100644 --- a/frontend/src/components/CookieConsent.vue +++ b/frontend/src/components/CookieConsent.vue @@ -6,18 +6,17 @@ role="region" aria-label="Cookie consent" > - + diff --git a/frontend/src/components/CopySnippet.vue b/frontend/src/components/CopySnippet.vue index 53d99d83cd..f56aa12450 100644 --- a/frontend/src/components/CopySnippet.vue +++ b/frontend/src/components/CopySnippet.vue @@ -2,7 +2,7 @@
- Copy + {{ $t('ui.copy') }}
diff --git a/frontend/src/components/DevicesBrowser.vue b/frontend/src/components/DevicesBrowser.vue index 241c36140a..d2fa669009 100644 --- a/frontend/src/components/DevicesBrowser.vue +++ b/frontend/src/components/DevicesBrowser.vue @@ -2,16 +2,16 @@
- + @@ -159,18 +159,16 @@ - + @@ -195,20 +193,20 @@ - + @@ -224,7 +222,7 @@ - + @@ -234,20 +232,20 @@ - + @@ -263,14 +261,14 @@ - +
- No Remote Instances found. + {{ $t('ui.noRemoteInstancesFound') }}
@@ -288,10 +286,10 @@ > @@ -319,9 +317,9 @@
-

This action cannot be undone.

+

{{ $t('ui.thisActionCannotBeUndone') }}

@@ -349,9 +347,9 @@ @confirm="moveDevicesToUnassigned(checkedDevices)" > diff --git a/frontend/src/components/FinishSetup.vue b/frontend/src/components/FinishSetup.vue index 9b870f6d55..950023a149 100644 --- a/frontend/src/components/FinishSetup.vue +++ b/frontend/src/components/FinishSetup.vue @@ -1,7 +1,7 @@ diff --git a/frontend/src/components/GoogleLoginButton.vue b/frontend/src/components/GoogleLoginButton.vue index a13389cc14..58ea90ca27 100644 --- a/frontend/src/components/GoogleLoginButton.vue +++ b/frontend/src/components/GoogleLoginButton.vue @@ -27,6 +27,8 @@ import { computed, ref } from 'vue' import { useRoute } from 'vue-router' import { GoogleLogin } from 'vue3-google-login' +import { t } from '../i18n.js' + import SSOApi from '@/api/sso.js' import SpinnerIcon from '@/components/icons/Spinner.js' import { useAccountSettingsStore } from '@/stores/account-settings.js' @@ -36,7 +38,7 @@ withDefaults(defineProps<{ label?: string disabled?: boolean }>(), { - label: 'Sign In with Google', + label: t('ui.signInWithGoogle'), disabled: false }) diff --git a/frontend/src/components/JsonViewer.vue b/frontend/src/components/JsonViewer.vue index ccf055974a..baa7132f63 100644 --- a/frontend/src/components/JsonViewer.vue +++ b/frontend/src/components/JsonViewer.vue @@ -26,7 +26,7 @@ :title="wrapped ? 'Word wrap on' : 'Word wrap off'" @click="wrapped = !wrapped" > - Wrap + {{ $t('ui.wrap') }}
- + diff --git a/frontend/src/components/Offline.vue b/frontend/src/components/Offline.vue index 1c3e7abc67..1e2a53cf00 100644 --- a/frontend/src/components/Offline.vue +++ b/frontend/src/components/Offline.vue @@ -1,10 +1,10 @@ diff --git a/frontend/src/components/SectionTopMenu.vue b/frontend/src/components/SectionTopMenu.vue index 1975026847..315b6ffbc6 100644 --- a/frontend/src/components/SectionTopMenu.vue +++ b/frontend/src/components/SectionTopMenu.vue @@ -34,7 +34,7 @@
diff --git a/frontend/src/components/SelectInstance.vue b/frontend/src/components/SelectInstance.vue index fd6680f9a6..0bd4cdba67 100644 --- a/frontend/src/components/SelectInstance.vue +++ b/frontend/src/components/SelectInstance.vue @@ -3,10 +3,10 @@ v-model="input.application" :options="options.applications" :disabled="noApplications || loading.applications" - placeholder="Select an application" + :placeholder="$t('ui.selectAnApplication')" data-form="application" > - Application + {{ $t('ui.application') }} - Node-RED Instance + {{ $t('ui.nodeRedInstance') }}