ObjectStackObjectStack

Internationalization Standard

Translation bundles, locale resolution, pluralization, date/number formatting, and dynamic loading

Internationalization (i18n) Standard

Protocol spec, partial implementation. The runtime i18n service (@objectstack/service-i18n) currently implements translation bundles (loadTranslations / getTranslations), locale listing, fallback-locale resolution, and {{param}} interpolation via the II18nService contract (t(key, locale, params)) — see packages/services/service-i18n for the current API. The number/date/relative-time/currency formatting helpers, the plural() helper, ICU MessageFormat parsing, setUserLocale, and the nested config shapes shown below (e.g. i18n.translationService.provider, auto-translate hooks) describe target behaviour and are not yet implemented. Treat those snippets as design intent.

ObjectStack provides built-in internationalization that enables applications to support multiple languages, locales, and regional formats without external libraries or complex configuration.

The i18n Problem

Traditional i18n implementations require integrating multiple libraries:

// Typical i18n setup requires 3+ libraries
import i18next from 'i18next';           // Translation engine
import { formatNumber } from 'numeral';  // Number formatting
import { formatDate } from 'date-fns';   // Date formatting
import pluralize from 'pluralize';       // Pluralization

// Each library has different APIs, config, and quirks
i18next.t('messages.welcome', { name: 'John' });
formatNumber(1234.56, '0,0.00');
formatDate(new Date(), 'PP');
pluralize('item', 5);

Result: 500KB+ of dependencies, inconsistent APIs, and hours of configuration.

ObjectStack i18n Solution

ObjectStack provides a unified i18n API with batteries included:

// Today: translation + interpolation via the II18nService contract
const i18n = kernel.getService('i18n');

i18n.t('messages.welcome', 'en', { name: 'John' }); // Translation + interpolation

// Design intent (not yet implemented): unified locale-aware formatting helpers
//   formatNumber(1234.56)        — locale-aware number formatting
//   formatDate(new Date(), 'medium') — locale-aware date formatting
//   plural('item', 5)            — pluralization

Benefits:

  • ✅ Zero external dependencies
  • ✅ Consistent API across all i18n operations
  • ✅ Automatic locale detection
  • ✅ Plugin-based translation bundles
  • ✅ Less than 50KB total footprint

Locale Resolution

ObjectStack automatically determines the user's locale using a fallback chain:

Partial implementation. The server resolves the request locale from the Accept-Language header (highest-priority entry), then a ?locale= query parameter, then the i18n service's default locale — see extractLocale in packages/rest/src/rest-server.ts. The DB-persisted user preference, tenant default, and IP geolocation steps shown below are design intent: there is no geolocation or tenant-locale resolution in the runtime today.

┌─────────────────────────────────────────────────────────────┐
│ 1. USER PREFERENCE                                          │
│    Explicit user setting (saved in database)                │
│    Example: User selects "Deutsch" in profile settings      │
└─────────────────────────────────────────────────────────────┘
                          ↓ fallback
┌─────────────────────────────────────────────────────────────┐
│ 2. TENANT DEFAULT                                           │
│    Organization-wide locale setting                         │
│    Example: German company sets 'de' as tenant default      │
└─────────────────────────────────────────────────────────────┘
                          ↓ fallback
┌─────────────────────────────────────────────────────────────┐
│ 3. ACCEPT-LANGUAGE HEADER                                   │
│    Browser sends preferred languages                        │
│    Example: 'de-AT, de;q=0.9, en;q=0.8'                     │
└─────────────────────────────────────────────────────────────┘
                          ↓ fallback
┌─────────────────────────────────────────────────────────────┐
│ 4. IP GEOLOCATION                                           │
│    Detect country from IP address                           │
│    Example: IP from Austria → 'de-AT'                       │
└─────────────────────────────────────────────────────────────┘
                          ↓ fallback
┌─────────────────────────────────────────────────────────────┐
│ 5. SYSTEM DEFAULT                                           │
│    Configured in objectstack.config.ts                      │
│    Example: 'en' for US-based companies                     │
└─────────────────────────────────────────────────────────────┘

Locale Format

Locales follow BCP 47 standard: language-REGION

Examples:

  • en — English (generic)
  • en-US — English (United States)
  • en-GB — English (United Kingdom)
  • de — German (generic)
  • de-DE — German (Germany)
  • de-AT — German (Austria)
  • zh-CN — Chinese (Simplified, China)
  • zh-TW — Chinese (Traditional, Taiwan)

Locale Fallback

If exact locale not found, ObjectStack falls back to language-only locale:

User requests: de-AT (German, Austria)

Check for translation: de-AT ✗ (not found)

Fallback to: de ✓ (found)

Use: de (German generic)

If language not found, fall back to system default:

User requests: pt-BR (Portuguese, Brazil)

Check for translation: pt-BR ✗ (not found)

Fallback to: pt ✗ (not found)

Fallback to: en (system default) ✓

Translation Bundles

Translations are stored in JSON files organized by locale and namespace.

Object-First Convention

ObjectStack uses an object-first convention where all translatable metadata for an object is aggregated under objects.{object_name}. Global (non-object-bound) translations remain in dedicated top-level groups. This aligns with Salesforce DX and Dynamics conventions, enabling efficient translation workbench editing and automated coverage detection.

There is exactly one shape. A file-authored bundle is a map of locale code → TranslationData; a translation metadata item authored at runtime is one TranslationData plus the locale it translates. The resolvers, os i18n extract, os i18n check, and the Studio editor all read the same keys.

// One locale of a TranslationBundle (e.g. zh-CN)
const zh: TranslationData = {
  // ── Object-first translations ─────────────────────────────────
  objects: {
    account: {
      label: '客户',
      pluralLabel: '客户',
      description: '客户管理对象',
      fields: {
        name: { label: '客户名称', help: '公司法定名称' },
        industry: { label: '行业', options: { tech: '科技', finance: '金融' } },
        status: { options: { active: '活跃', inactive: '停用' } },
      },
      _views:  { all_accounts: { label: '全部客户' } },
      _sections: { basic_info: { label: '基本信息' } },
      _actions: { convert: { label: '转换', confirmText: '确认转换?' } },
    },
  },

  // ── Global translations ───────────────────────────────────────
  apps: { crm: { label: '客户关系管理', navigation: { home: { label: '首页' } } } },
  dashboards: { sales_overview: { label: '销售概览' } },
  pages: { landing: { title: '欢迎' } },
  globalActions: { export_csv: { label: '导出 CSV' } },
  messages: { 'common.save': '保存' },
};

Key benefits:

  • ✅ All translatable content for one object in one place
  • ✅ CLI can generate translation skeletons per object
  • ✅ Workbench can show per-object coverage and diffs
  • ✅ One shape for files and runtime authoring — what you author is what renders

Retired: the o.{object} dialect. A second, object-first shape keyed on o. (with app, nav, dashboard, _globalOptions, _meta) was once documented here for runtime-authored translations. No resolver ever read it, so translations authored in that shape saved successfully and rendered nothing. It was removed in #3778, which rejected those ten keys at the metadata door; #4001 closed both shapes entirely, so any undeclared key is now rejected in a runtime item and in a file-authored bundle alike, with the retired ones still naming the group to use instead. Author everything — files and runtime items alike — under objects..

Authoring a translation item at runtime

An admin (or an agent using the metadata API) authors one item per locale. The only difference from a file bundle is the top-level locale:

import { defineTranslation } from '@objectstack/spec/system';

export default defineTranslation({
  locale: 'zh-CN',
  objects: {
    account: {
      label: '客户',
      fields: { name: { label: '客户名称' } },
    },
  },
  messages: { 'common.save': '保存' },
});

locale is required — the runtime sync skips an item whose locale it cannot resolve, and a silent skip is exactly what makes a missing translation hard to diagnose. Items are merged over the static file bundles, so an authored value wins over a shipped one for the same key.

Directory Structure

my-plugin/
  i18n/
    en/
      common.json          # Common translations
      account.json         # Account object translations
      errors.json          # Error messages
    de/
      common.json
      account.json
      errors.json
    es/
      common.json
      account.json
      errors.json

Translation File Format

// i18n/en/account.json
{
  // Simple strings
  "account": "Account",
  "accounts": "Accounts",
  
  // Nested keys
  "fields": {
    "name": "Account Name",
    "industry": "Industry",
    "revenue": "Annual Revenue"
  },
  
  // Interpolation
  "welcome": "Welcome, {{name}}!",
  "accountCount": "You have {{count}} accounts",
  
  // Pluralization
  "itemCount": "{{count}} item",
  "itemCount_plural": "{{count}} items",
  
  // Context-specific
  "delete": "Delete",
  "delete_confirm": "Are you sure you want to delete {{name}}?",
  
  // Rich formatting
  "lastUpdated": "Last updated {{date, datetime}}",
  "totalValue": "Total: {{amount, currency}}",
  
  // Arrays (for select options, etc.)
  "industries": [
    { "value": "technology", "label": "Technology" },
    { "value": "finance", "label": "Finance" },
    { "value": "healthcare", "label": "Healthcare" }
  ]
}
// i18n/de/account.json
{
  "account": "Konto",
  "accounts": "Konten",
  
  "fields": {
    "name": "Kontoname",
    "industry": "Branche",
    "revenue": "Jahresumsatz"
  },
  
  "welcome": "Willkommen, {{name}}!",
  "accountCount": "Sie haben {{count}} Konten",
  
  "itemCount": "{{count}} Artikel",
  "itemCount_plural": "{{count}} Artikel",
  
  "delete": "Löschen",
  "delete_confirm": "Möchten Sie {{name}} wirklich löschen?",
  
  "lastUpdated": "Zuletzt aktualisiert am {{date, datetime}}",
  "totalValue": "Gesamt: {{amount, currency}}",
  
  "industries": [
    { "value": "technology", "label": "Technologie" },
    { "value": "finance", "label": "Finanzen" },
    { "value": "healthcare", "label": "Gesundheitswesen" }
  ]
}

Translation API

Basic Translation

The contract is t(key, locale, params?) — the target locale is always passed explicitly, and the key itself is returned when no translation is found (there is no defaultValue option).

const i18n = kernel.getService('i18n');

// Translate a key for a given locale, with {{param}} interpolation
const message = i18n.t('account.welcome', 'en', { name: 'John' });
// en: "Welcome, John!"
// de (locale 'de'): "Willkommen, John!"

// Dot-notation resolves nested keys
const fieldLabel = i18n.t('account.fields.name', 'de');
// "Kontoname"

// Missing keys fall back to the fallbackLocale, then return the key string
const label = i18n.t('account.fields.unknown', 'en');
// "account.fields.unknown"

Pluralization

Design intent. Automatic CLDR plural selection (the key / key_plural convention) is not yet implemented by the file adapter — t() performs only {{param}} interpolation. To pluralize today, select the plural form in application code before calling t().

// Target behaviour (not yet wired into the file adapter):
i18n.t('account.itemCount', 'en', { count: 1 }); // "1 item"
i18n.t('account.itemCount', 'en', { count: 5 }); // "5 items"

Planned plural forms:

  • English: key, key_plural
  • Other languages may have more forms (e.g., Polish has 5)

The intent is to use Unicode CLDR plural rules to select the correct form.

Context-Based Translation

// Distinct keys for distinct contexts — both resolved with t(key, locale)
const deleteButton = i18n.t('account.delete', 'en');
// "Delete"

const deleteConfirmation = i18n.t('account.delete_confirm', 'en', {
  name: 'Acme Corp',
});
// "Are you sure you want to delete Acme Corp?"

Date and Time Formatting

Design intent — not yet implemented. The formatDate, formatTime, formatDateTime, and formatRelativeTime methods are not part of the current II18nService contract (packages/spec/src/contracts/i18n-service.ts) and have no runtime implementation in @objectstack/service-i18n. The examples below describe the planned locale-aware formatting API; until it ships, use the platform Intl.DateTimeFormat / Intl.RelativeTimeFormat APIs directly.

The planned API provides locale-aware date/time formatting using Unicode CLDR data.

Date Formatting

const date = new Date('2024-01-15T14:30:00Z');

// Predefined formats
context.i18n.formatDate(date, 'short');
// en-US: "1/15/24"
// en-GB: "15/01/24"
// de-DE: "15.01.24"

context.i18n.formatDate(date, 'medium');
// en-US: "Jan 15, 2024"
// de-DE: "15. Jan. 2024"

context.i18n.formatDate(date, 'long');
// en-US: "January 15, 2024"
// de-DE: "15. Januar 2024"

context.i18n.formatDate(date, 'full');
// en-US: "Monday, January 15, 2024"
// de-DE: "Montag, 15. Januar 2024"

// Custom format (ICU pattern)
context.i18n.formatDate(date, { pattern: 'yyyy-MM-dd' });
// "2024-01-15" (same across locales)

Time Formatting

const time = new Date('2024-01-15T14:30:00Z');

context.i18n.formatTime(time, 'short');
// en-US: "2:30 PM"
// de-DE: "14:30"

context.i18n.formatTime(time, 'medium');
// en-US: "2:30:00 PM"
// de-DE: "14:30:00"

context.i18n.formatTime(time, 'long');
// en-US: "2:30:00 PM GMT"
// de-DE: "14:30:00 GMT"

DateTime Formatting

const dateTime = new Date('2024-01-15T14:30:00Z');

context.i18n.formatDateTime(dateTime, 'short');
// en-US: "1/15/24, 2:30 PM"
// de-DE: "15.01.24, 14:30"

context.i18n.formatDateTime(dateTime, 'medium');
// en-US: "Jan 15, 2024, 2:30:00 PM"
// de-DE: "15. Jan. 2024, 14:30:00"

Relative Time

const pastDate = new Date(Date.now() - 3600000); // 1 hour ago

context.i18n.formatRelativeTime(pastDate);
// en: "1 hour ago"
// de: "vor 1 Stunde"

const futureDate = new Date(Date.now() + 86400000); // 1 day from now

context.i18n.formatRelativeTime(futureDate);
// en: "in 1 day"
// de: "in 1 Tag"

Timezone Handling

// Format in specific timezone
context.i18n.formatDateTime(date, 'medium', {
  timeZone: 'America/New_York',
});
// "Jan 15, 2024, 9:30:00 AM"

// Format in user's timezone (from context)
context.i18n.formatDateTime(date, 'medium', {
  timeZone: context.user.timezone, // e.g., 'Europe/Berlin'
});

Number Formatting

Design intent — not yet implemented. formatNumber, formatCurrency, and formatPercent are not part of the current II18nService contract and have no runtime implementation. The examples below describe the planned API; until it ships, use Intl.NumberFormat directly.

The planned API provides locale-aware number formatting for decimals, currency, and percentages.

Decimal Numbers

const number = 1234567.89;

context.i18n.formatNumber(number);
// en-US: "1,234,567.89"
// de-DE: "1.234.567,89"
// fr-FR: "1 234 567,89"

// Control decimal places
context.i18n.formatNumber(number, {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
});
// en-US: "1,234,567.89"

Currency

const amount = 1234.56;

context.i18n.formatCurrency(amount, 'USD');
// en-US: "$1,234.56"
// de-DE: "1.234,56 $"
// ja-JP: "$1,234.56"

context.i18n.formatCurrency(amount, 'EUR');
// en-US: "€1,234.56"
// de-DE: "1.234,56 €"
// fr-FR: "1 234,56 €"

// Control symbol display
context.i18n.formatCurrency(amount, 'USD', {
  currencyDisplay: 'code',
});
// "USD 1,234.56"

context.i18n.formatCurrency(amount, 'USD', {
  currencyDisplay: 'name',
});
// "1,234.56 US dollars"

Percentages

const percent = 0.1234;

context.i18n.formatPercent(percent);
// en-US: "12.34%"
// de-DE: "12,34 %"

// Control decimal places
context.i18n.formatPercent(percent, {
  minimumFractionDigits: 0,
  maximumFractionDigits: 0,
});
// en-US: "12%"

Compact Notation

const bigNumber = 1234567;

context.i18n.formatNumber(bigNumber, {
  notation: 'compact',
});
// en-US: "1.2M"
// de-DE: "1,2 Mio."

const smallNumber = 1234;

context.i18n.formatNumber(smallNumber, {
  notation: 'compact',
  compactDisplay: 'short',
});
// en-US: "1.2K"

Plugin Integration

Translations are metadata, declared on the stack — not manifest entries. (The former contributes.translations manifest key was removed in v17 (#10724, ADR-0049): no loader ever read its { locale, path } entries, so a manifest that still carries it now fails the parse with the upgrade prescription.)

A package declares a translation bundle and registers it in its stack's translations collection:

// src/translations/crm.translation.ts
import { defineTranslationBundle } from '@objectstack/spec/system';

export const CrmTranslationBundle = defineTranslationBundle({
  en: {
    objects: {
      crm_account: {
        label: 'Account',
        pluralLabel: 'Accounts',
        fields: {
          name: { label: 'Account Name' },
        },
      },
    },
  },
  de: {
    objects: {
      crm_account: {
        label: 'Konto',
        pluralLabel: 'Konten',
        fields: {
          name: { label: 'Kontoname' },
        },
      },
    },
  },
});
// objectstack.config.ts
import { defineStack } from '@objectstack/spec';
import { CrmTranslationBundle } from './src/translations/crm.translation.js';

export default defineStack({
  manifest: {
    id: 'com.mycompany.crm',
    version: '1.0.0',
    type: 'app',
    name: 'CRM',
  },
  translations: [CrmTranslationBundle],
});

The engine registers the collection as translation metadata (the governed translation type), and the i18n pipeline serves it.

Using Plugin Translations

// In plugin code — resolve the i18n service from the kernel/context,
// then pass the target locale explicitly: t(key, locale, params?)
const i18n = ctx.getService('i18n');

const label = i18n.t('crm.account.fields.name', 'en');
// en: "Account Name"
// de (locale 'de'): "Kontoname"

// Cross-plugin translation access — keys from any installed plugin resolve
const welcome = i18n.t('crm.account.welcome', 'en');
// Works if @mycompany/crm plugin is installed

ObjectQL Integration

ObjectQL objects and fields can be automatically translated:

// Object definition
import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Account = ObjectSchema.create({
  name: 'account',
  sharingModel: 'private',
  label: 'account.label',           // Translation key
  pluralLabel: 'account.pluralLabel',
  icon: 'building',
  
  fields: {
    name: Field.text({
      label: 'account.fields.name', // Translation key
    }),
    
    industry: Field.select({
      label: 'account.fields.industry',
      options: [
        { value: 'technology', label: 'account.industries.technology' },
        { value: 'finance', label: 'account.industries.finance' },
      ],
    }),
  },
});
// i18n/en/account.json
{
  "label": "Account",
  "pluralLabel": "Accounts",
  "fields": {
    "name": "Account Name",
    "industry": "Industry"
  },
  "industries": {
    "technology": "Technology",
    "finance": "Finance"
  }
}

ObjectUI resolves these label keys against the i18n service when rendering, so a field whose label is 'account.fields.name' displays "Account Name" in en and "Kontoname" in de. The raw label value stored on the metadata is the translation key; the rendered locale is supplied by the request's resolved locale.

ObjectUI Integration

ObjectUI automatically translates labels, placeholders, and error messages:

// View definition — `defineView` takes a container of named views
// ({ list, form, listViews, formViews }); a flat { name, type, ... }
// object is rejected at build time.
export default defineView({
  list: {
    type: 'grid',
    label: 'account.list.title', // Translation key (i18n label)
    data: { provider: 'object', object: 'account' },
    columns: [
      'name', // Column label auto-translated from the ObjectQL field definition
    ],
  },
});
// i18n/en/account.json
{
  "list": {
    "title": "Accounts"
  },
  "actions": {
    "create": "New Account"
  }
}
// i18n/de/account.json
{
  "list": {
    "title": "Konten"
  },
  "actions": {
    "create": "Neues Konto"
  }
}

Dynamic Locale Switching

The i18n service resolves translations per call — t(key, locale, params) takes the target locale as an explicit argument. The persisted user-locale preference and a UI "switch locale without reload" flow are design intent, not yet exposed as service methods (there is no setUserLocale or context.ui.reload).

The II18nService contract translates against whatever locale you pass in, so "switching" a locale means resolving the same keys against a different locale code:

const i18n = kernel.getService('i18n');

// Resolve the same key against different locales
i18n.t('messages.welcome', 'en'); // "Welcome!"
i18n.t('messages.welcome', 'de'); // "Willkommen!"

The locale a request renders in comes from the application's locale-resolution chain (user preference → tenant default → Accept-Language → system default); persisting a user's choice is left to the host application.

Translation Management

CLI Tools

The os CLI ships two i18n commands: extract scaffolds per-locale translation skeletons from your stack config, and check reports coverage / missing keys.

# Scaffold per-locale translation skeletons from the stack config
os i18n extract --locales=zh-CN,ja-JP --out=./src/translations

# Print the skeletons as JSON instead of writing files
os i18n extract --json

# Detect missing translation keys across all configured locales
os i18n check

# Fail CI when any locale falls below a coverage threshold
os i18n check --strict --threshold=95

Translation Coverage

# Check translation coverage
os i18n check

Output:
  en: 100% (450/450 keys)
  de: 95%  (428/450 keys) - Missing: 22 keys
  es: 80%  (360/450 keys) - Missing: 90 keys
  
  Missing keys in de:
    - account.fields.new_field
    - account.actions.bulk_delete
    ...

Orphan Keys and Option Keys

os i18n check runs in one direction — which keys the metadata expects that no bundle carries. The reverse direction (keys a bundle carries that no metadata claims) is checked by os validate, os lint, and os compile, which walk every bundle against the stack it ships with:

KeyMust name
objects.{object}an object this stack defines, or a platform object
objects.{object}.fields.{field}a field that object declares
objects.{object}.fields.{field}.options.{key}an option's stored value
objects.{object}._views.{view}a view's name
objects.{object}._actions.{action}an action bound to that object
objects.{object}._actions.{action}.params.{param}a param's name
objects.{object}._sections.{section}a fieldGroups[].key, or a section with a name
apps.{app} / apps.{app}.navigation.{id}an app's name / a navigation item's id
dashboards.{dash} / .widgets.{id} / .actions.{actionUrl}the dashboard's name / a widget id / a header actionUrl
globalActions.{action}an action with no objectName

An unresolvable key is a warning, not an error: nothing crashes, the string simply renders in its source locale while every neighbouring label resolves — so the app looks translated and one field does not.

The option case is worth spelling out. Option maps are keyed by the option's stored value, never by its display label:

// source: Field.select({ options: [{ value: 'direct_mail', label: 'Direct Mail' }] })

options: { direct_mail: '直邮' }    // ✅ keyed by value
options: { 'Direct Mail': '直邮' }  // ❌ keyed by the label — never resolves
options: { 'direct-mail': '直邮' }  // ❌ a variant spelling of the value

messages, settings, settingsCommon and metadataForms are not checked: their keys are owned by application code, plugins, and the platform's own metadata-type registry rather than by this stack's metadata, so there is no set of legal names to resolve against.

Translation Service Integration

Design intent — not yet implemented. i18n.translationService is not a recognized key on TranslationConfigSchema (packages/spec/src/system/translation.zod.ts), and no auto-translate provider integration ships today. The snippet below describes planned behaviour — since #4001 the config shape is closed, so copying it into a real defineStack is a build-time rejection rather than a key silently dropped on the floor.

ObjectStack integrates with professional translation services:

// objectstack.config.ts
export default defineStack({
  i18n: {
    translationService: {
      provider: 'google-translate',
      apiKey: process.env.GOOGLE_TRANSLATE_API_KEY,
      
      // Auto-translate missing keys
      autoTranslate: true,
      
      // Target languages
      languages: ['de', 'es', 'fr', 'ja', 'zh-CN'],
    },
  },
});

Supported Services:

  • Google Cloud Translation
  • AWS Translate
  • DeepL
  • Microsoft Translator

Best Practices

1. Use Namespaces for Organization

// ✓ GOOD: Organized by feature
{
  "account": { "label": "Account" },
  "contact": { "label": "Contact" },
  "opportunity": { "label": "Opportunity" }
}

// ✗ BAD: Flat structure
{
  "accountLabel": "Account",
  "contactLabel": "Contact",
  "opportunityLabel": "Opportunity"
}

2. Provide Context in Keys

// ✓ GOOD: Context clear
{
  "delete_button": "Delete",
  "delete_confirm_message": "Are you sure?"
}

// ✗ BAD: Ambiguous
{
  "delete": "Delete",
  "confirm": "Are you sure?"
}

3. Don't Translate Technical Identifiers

// ✓ GOOD: Labels translated, values not
{
  "industries": [
    { "value": "technology", "label": "Technology" },
    { "value": "finance", "label": "Finance" }
  ]
}

// ✗ BAD: Values translated (breaks code)
{
  "industries": [
    { "value": "technologie", "label": "Technologie" }
  ]
}

4. Test All Locales

// ✓ GOOD: Test with actual locales
describe('Account View', () => {
  it('should display in German', async () => {
    const context = createContext({ locale: 'de' });
    const view = await renderView('account_list', { context });
    expect(view.title).toBe('Konten');
  });
});

Configuration

// objectstack.config.ts
export default defineStack({
  i18n: {
    // Default locale
    defaultLocale: 'en',
    
    // Supported locales
    supportedLocales: ['en', 'de', 'es', 'fr', 'ja', 'zh-CN'],
    
    // Fallback chain
    fallbackLocale: 'en',

    // Translation service — design intent, not yet a recognized config key
    // translationService: {
    //   provider: 'google-translate',
    //   apiKey: process.env.GOOGLE_TRANSLATE_API_KEY,
    // },
  },
});

Summary

ObjectStack i18n provides:

  • Translation bundles in JSON format with object-first namespaces
  • Fallback-locale resolution for missing keys
  • {{param}} interpolation via t(key, locale, params)
  • Plugin integration for modular translation management
  • ObjectUI integration for automatic metadata-label translation
  • Translation management toolsos i18n extract and os i18n check

Planned (design intent, not yet implemented): locale-aware date/number/currency formatting helpers, CLDR pluralization, persisted user-locale switching, and translation-service auto-translate hooks.

Result: Build globally-ready applications without wrestling with i18n libraries.

On this page