| title | Translations |
|---|---|
| description | Labels and UI text as metadata — one bundle per locale, resolved per request, with CLI tooling to draft and to gate coverage in CI. |
Every label in an ObjectStack app — object and field names, picklist options, view titles, action buttons, app navigation — is metadata, not a string baked into a component. Adding a language means adding a bundle, not touching the app.
**Single-locale apps need none of this.** `translations` and `i18n` are both optional; with no bundle the runtime falls back to the labels declared on your metadata. Built-in system fields (`owner_id`, `created_at`, …) carry their own labels either way.A bundle is a map of locale → translations, registered on your stack:
import { defineTranslationBundle } from '@objectstack/spec';
export const CrmTranslationBundle = defineTranslationBundle({
en: {
objects: {
crm_account: {
label: 'Account',
pluralLabel: 'Accounts',
fields: {
name: { label: 'Account Name' },
industry: { label: 'Industry' },
},
},
},
},
'zh-CN': {
objects: {
crm_account: {
label: '客户',
pluralLabel: '客户',
fields: {
name: { label: '客户名称' },
industry: { label: '行业' },
},
},
},
},
});export default defineStack({
// ...
translations: [CrmTranslationBundle],
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
});| Surface | Where it lives in the bundle |
|---|---|
| Object label / plural / description | objects.<name>.label / pluralLabel / description |
| Field labels, help text, placeholders | objects.<name>.fields.<field>.label / help / placeholder |
| Picklist option labels | objects.<name>.fields.<field>.options.<value> |
| View titles, descriptions, empty states | objects.<name>._views.<view> |
| Action labels, confirm text, success messages | objects.<name>._actions.<action> |
| Action result dialogs (title / description / acknowledge / field labels) | objects.<name>._actions.<action>.resultDialog |
| Form sections | objects.<name>._sections.<section> |
| App navigation | apps.<app>.navigation.<id>.label |
| Dashboards and widgets | dashboards.<name> |
Page labels and page:header copy |
pages.<name>.label / description / title / subtitle |
| Global actions, settings, messages | globalActions, settings, messages |
The metadata types resolved per request are object, view, action, app, dashboard, and page — a field's labels are translated as part of its object document.
**Page headers are keyed by page name (#3589).** A page's `page:header` component has no stable id, so its `properties.title` / `properties.subtitle` are addressed through the page itself: `pages..title` / `subtitle`. `title` falls back to `pages..label`, so a page whose header title matches its nav label needs only `label`. Every `page:header` in the page's regions receives the same copy. **One-shot result dialogs are translatable (#3347).** The post-success `resultDialog` shown by actions like `create_user` (temporary password), 2FA backup codes, and OAuth client-secret rotation carries its own `_actions..resultDialog` slot (`title` / `description` / `acknowledge` and `fields` keyed by the literal result-field path, e.g. `"user.email"`). `os i18n extract` emits these keys; the shipped platform dialogs ship en / zh-CN / ja-JP / es-ES copy. Separately, **platform notification and storage strings localize to the recipient's locale** — collaboration assignment / @mention bell titles resolve in the locale of whoever reads the bell (not the actor), and `sys_file` / `sys_upload_session` ship their own bundles so the file-detail page and its status pipeline are localized (#3354).Per request, in this order:
- The
Accept-Languageheader (the client SDK sends it;setLocale()sets it) - A
?locale=query parameter - The stack's
defaultLocale
Within the bundle, matching walks: exact (zh-CN) → case-insensitive →
base language (zh-CN → zh) → variant expansion (zh → zh-CN). If
nothing matches, the chain falls back to en, and finally to the literal label
on the metadata. Translation lookup never throws — a missing string
degrades to the next best text.
Resolved labels are served straight from the REST metadata endpoints (the locale is part of the ETag), so the Console and any SDUI client get translated metadata without doing lookups themselves.
How you lay out translation source files is an authoring convention — your import graph assembles whichever layout you choose into the bundles you register. Common layouts:
- per locale — one file per language, combined in the bundle.
This is what the Todo example does (
en,zh-CN,ja-JP). - bundled — every locale in one file, like the CRM example above. Fine for two locales; unwieldy past that.
- per namespace — split by module.
# Scaffold entries for everything translatable that isn't yet
npx os i18n extract --locales zh-CN --fill todo --out src/translations
# Report coverage; fail the build when it slips
npx os i18n check --strict --threshold 95
# Fail if the committed bundles have fallen behind the schema
npx os i18n extract --locales zh-CN --fill todo --out src/translations --checkos i18n check exits non-zero on violations, so it works as a CI gate.
A missing string in the default locale is an error; missing strings in
other locales are warnings until you set --strict / --threshold. The Todo
example ships a completeness test alongside its bundles — worth copying.
The two gates answer different questions, and you want both. os i18n check
asks are the strings translated? — a coverage number about human work.
os i18n extract --check asks are the generated bundles still what the schema
produces? — a freshness check about machine output. Renaming a field's label,
adding an object, or removing a spec key leaves coverage at 100% while the
bundles quietly go stale, which is exactly how this repo's own bundles ended up
carrying translations for keys the schema had deleted (#3670).
--check writes nothing: it re-renders and diffs against --out, naming each
stale file and printing the regenerate command. It runs in the same merge
mode as a normal extract, so it never asks anyone to re-translate — an
up-to-date bundle re-extracts byte-identically.
Honest limits worth knowing before you plan around them:
validationMessageshas no runtime consumer today. The schema accepts it and the coverage tooling counts it, but nothing reads it back — validation errors are not translated through bundles yet.- No ICU MessageFormat — plural/gender formatting isn't available;
interpolation is always simple
{variable}substitution. - Two bundle shapes exist. File-authored bundles use the
objects.<name>shape documented here, which is what the resolver reads. Thetranslationmetadata type authored at runtime uses an object-first (o.<object>) shape, and the two are not bridged — author translations as files for now. - AI-suggested translation fields (
aiSuggested,aiConfidence) are schema only.
- Views · Apps · Actions — the surfaces you're translating
- Objects — where the fallback labels are declared
- i18n standard — the normative protocol
- Translation schema reference — every field, generated from the spec