Skip to content

Latest commit

 

History

History
907 lines (722 loc) · 25.9 KB

File metadata and controls

907 lines (722 loc) · 25.9 KB
title Internationalization Standard
description Translation bundles, locale resolution, pluralization, date/number formatting, and dynamic loading

import { Globe, Languages, Calendar, Hash, FileText, MapPin } from 'lucide-react';

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 (Recommended)

ObjectStack uses an object-first convention where all translatable metadata for an object is aggregated under o.{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.

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

  // ── Global translations ───────────────────────────────────────
  _globalOptions: { currency: { usd: '美元', eur: '欧元' } },
  app: { crm: { label: '客户关系管理' } },
  nav: { home: '首页', settings: '设置' },
  dashboard: { sales_overview: { label: '销售概览' } },
  reports: { pipeline_report: { label: '管道报表' } },
  pages: { landing: { title: '欢迎' } },
  messages: { 'common.save': '保存' },
  validationMessages: { 'discount_limit': '折扣不能超过40%' },
};

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
  • ✅ No redundant category/fieldOptions/reports nodes

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

Plugins register translation bundles in their manifest:

// plugin.manifest.ts — a plugin manifest validated by `ManifestSchema`
// from `@objectstack/spec/kernel` (there is no `definePlugin()` helper).
// Translation files are registered under `contributes.translations` as
// { locale, path } entries (see packages/spec/src/kernel/manifest.zod.ts).
const manifest = {
  id: 'com.mycompany.crm',
  name: '@mycompany/crm',
  version: '1.0.0',

  contributes: {
    translations: [
      { locale: 'en', path: 'i18n/en/account.json' },
      { locale: 'de', path: 'i18n/de/account.json' },
    ],
  },
};

export default manifest;

Translation File Registration

@mycompany/crm/
  i18n/
    en/
      account.json        → Namespace: crm.account
      contact.json        → Namespace: crm.contact
    de/
      account.json
      contact.json

Namespace Convention: {pluginName}.{filename}

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',
  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
    ...

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.

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.