Skip to content

Latest commit

 

History

History
188 lines (155 loc) · 7.93 KB

File metadata and controls

188 lines (155 loc) · 7.93 KB
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.

Translations

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.

Your first bundle

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'] },
});

What you can translate

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

How a locale is chosen

Per request, in this order:

  1. The Accept-Language header (the client SDK sends it; setLocale() sets it)
  2. A ?locale= query parameter
  3. The stack's defaultLocale

Within the bundle, matching walks: exact (zh-CN) → case-insensitive → base language (zh-CNzh) → variant expansion (zhzh-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.

Organizing the files

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.

Draft with the CLI, gate in CI

# 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 --check

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

Current boundaries

Honest limits worth knowing before you plan around them:

  • validationMessages has 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. The translation metadata 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.

Related