| 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';
**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.
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 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) — pluralizationBenefits:
- ✅ Zero external dependencies
- ✅ Consistent API across all i18n operations
- ✅ Automatic locale detection
- ✅ Plugin-based translation bundles
- ✅ Less than 50KB total footprint
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 │
└─────────────────────────────────────────────────────────────┘
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)
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) ✓
Translations are stored in JSON files organized by locale and namespace.
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
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
// 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" }
]
}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"// 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.
// 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?"The planned API provides locale-aware date/time formatting using Unicode CLDR data.
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)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"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"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"// 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'
});The planned API provides locale-aware number formatting for decimals, currency, and percentages.
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"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"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%"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"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;@mycompany/crm/
i18n/
en/
account.json → Namespace: crm.account
contact.json → Namespace: crm.contact
de/
account.json
contact.json
Namespace Convention: {pluginName}.{filename}
// 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 installedObjectQL 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 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"
}
}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.
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# 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
...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
// ✓ GOOD: Organized by feature
{
"account": { "label": "Account" },
"contact": { "label": "Contact" },
"opportunity": { "label": "Opportunity" }
}
// ✗ BAD: Flat structure
{
"accountLabel": "Account",
"contactLabel": "Contact",
"opportunityLabel": "Opportunity"
}// ✓ GOOD: Context clear
{
"delete_button": "Delete",
"delete_confirm_message": "Are you sure?"
}
// ✗ BAD: Ambiguous
{
"delete": "Delete",
"confirm": "Are you sure?"
}// ✓ GOOD: Labels translated, values not
{
"industries": [
{ "value": "technology", "label": "Technology" },
{ "value": "finance", "label": "Finance" }
]
}
// ✗ BAD: Values translated (breaks code)
{
"industries": [
{ "value": "technologie", "label": "Technologie" }
]
}// ✓ 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');
});
});// 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,
// },
},
});ObjectStack i18n provides:
- Translation bundles in JSON format with object-first namespaces
- Fallback-locale resolution for missing keys
{{param}}interpolation viat(key, locale, params)- Plugin integration for modular translation management
- ObjectUI integration for automatic metadata-label translation
- Translation management tools —
os i18n extractandos 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.