diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b761fa505..e483b5c79 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ # ๐Ÿ“œ ObjectStack Copilot Instructions -> **Last synced with repo structure:** 2026-04-02 +> **Last synced with repo structure:** 2026-04-13 **Role:** You are the **Chief Protocol Architect** for the ObjectStack ecosystem. **Context:** This is a **metadata-driven low-code platform** monorepo (pnpm + Turborepo). @@ -92,7 +92,8 @@ objectstack-ai/spec/ โ”‚ โ”œโ”€โ”€ objectstack-api/ โ”‚ โ”œโ”€โ”€ objectstack-ui/ โ”‚ โ”œโ”€โ”€ objectstack-automation/ -โ”‚ โ””โ”€โ”€ objectstack-ai/ +โ”‚ โ”œโ”€โ”€ objectstack-ai/ +โ”‚ โ””โ”€โ”€ objectstack-i18n/ โ”‚ โ””โ”€โ”€ content/docs/ # ๐Ÿ“ Documentation content โ”œโ”€โ”€ getting-started/ @@ -232,6 +233,7 @@ The `skills/` directory contains domain-specific AI skill definitions. When work | UI Design | `skills/objectstack-ui/SKILL.md` | Designing Views, Dashboards, Apps | | Automation Design | `skills/objectstack-automation/SKILL.md` | Designing Flows, Workflows, Triggers | | AI Agent Design | `skills/objectstack-ai/SKILL.md` | Designing Agents, Tools, RAG pipelines | +| **I18n Design** | `skills/objectstack-i18n/SKILL.md` | Translation bundles, locale config, coverage detection | --- diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md new file mode 100644 index 000000000..ee332c0e7 --- /dev/null +++ b/skills/objectstack-i18n/SKILL.md @@ -0,0 +1,696 @@ +--- +name: objectstack-i18n +description: > + Design internationalization (i18n) strategies for ObjectStack applications. + Use when creating multi-locale translation bundles, managing translation coverage, + configuring locale settings, or designing object-first translation structures. +license: Apache-2.0 +compatibility: Requires @objectstack/spec Zod schemas (v4+) +metadata: + author: objectstack-ai + version: "1.0" + domain: system + tags: i18n, translation, locale, internationalization, l10n +--- + +# Internationalization โ€” ObjectStack I18n Protocol + +Expert instructions for designing internationalization (i18n) and localization (l10n) +strategies using the ObjectStack specification. This skill covers translation bundle +structures, locale configuration, object-first translation patterns, coverage detection, +and integration with the I18nService. + +--- + +## When to Use This Skill + +- You are **configuring i18n** for a new ObjectStack project. +- You need to **create translation bundles** for multiple locales. +- You are designing **object-first translation structures** (per-object translation files). +- You need to **detect missing or stale translations** (coverage analysis). +- You are integrating **AI-powered translation suggestions**. +- You are implementing **locale-specific formatting** (dates, numbers, currency). +- You need to understand **translation file organization strategies** (bundled, per_locale, per_namespace). + +--- + +## Core Concepts + +### Translation Architecture Overview + +ObjectStack follows an **object-first translation model** inspired by Salesforce and Dynamics 365: + +1. **Object-First Aggregation**: All translatable content for an object (labels, fields, options, views, actions) is grouped under a single namespace: `o.{object_name}`. + +2. **Global Groups**: Non-object-bound translations (apps, navigation, messages) live at the top level. + +3. **Locale Files**: Each locale has its own complete translation bundle (e.g., `en.json`, `zh-CN.json`). + +4. **Coverage Detection**: The system can compare translation bundles against source metadata to identify missing, redundant, or stale entries. + +--- + +## Translation Configuration + +### Stack-Level I18n Config + +Configure i18n settings in your `objectstack.config.ts`: + +```typescript +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + i18n: { + defaultLocale: 'en', + supportedLocales: ['en', 'zh-CN', 'ja-JP', 'es-ES'], + fallbackLocale: 'en', + fileOrganization: 'per_locale', + messageFormat: 'simple', // or 'icu' for complex plurals + lazyLoad: false, + cache: true, + }, +}); +``` + +| Property | Type | Default | Description | +|:---------|:-----|:--------|:------------| +| `defaultLocale` | `string` | `'en'` | Default BCP-47 locale code | +| `supportedLocales` | `string[]` | `['en']` | All supported locales | +| `fallbackLocale` | `string` | same as `defaultLocale` | Fallback when translation missing | +| `fileOrganization` | `'bundled'` \| `'per_locale'` \| `'per_namespace'` | `'per_locale'` | How translation files are organized | +| `messageFormat` | `'simple'` \| `'icu'` | `'simple'` | Interpolation format (ICU for plurals/gender) | +| `lazyLoad` | `boolean` | `false` | Load translations on demand | +| `cache` | `boolean` | `true` | Cache loaded translations in memory | + +> **BCP-47 Locale Codes**: Use standard locale tags (e.g., `en-US`, `zh-CN`, `pt-BR`, `en-GB`). + +--- + +## File Organization Strategies + +### 1. Bundled (Single File) + +All locales in one file. Best for small projects with few objects. + +``` +src/translations/ + crm.translation.ts # { en: {...}, "zh-CN": {...} } +``` + +**When to use:** Fewer than 5 objects, 2-3 locales, < 200 translation keys total. + +### 2. Per-Locale (Recommended) + +One file per locale containing all namespaces. Recommended when a single locale file stays under ~500 lines. + +``` +src/translations/ + en.ts # TranslationData for English + zh-CN.ts # TranslationData for Chinese + ja-JP.ts # TranslationData for Japanese +``` + +**When to use:** Medium projects (5-20 objects), 3-5 locales, organized by language. + +### 3. Per-Namespace (Enterprise) + +One file per namespace (object) per locale. Recommended for large projects with many objects/languages. Aligns with Salesforce DX and ServiceNow conventions. + +``` +i18n/ + en/ + account.json # ObjectTranslationData + contact.json + project_task.json + common.json # messages + app labels + zh-CN/ + account.json + contact.json + project_task.json + common.json +``` + +**When to use:** Large projects (20+ objects), 5+ locales, team collaboration, CI/CD pipelines. + +--- + +## Object-First Translation Bundle + +### AppTranslationBundle Structure + +The `AppTranslationBundle` is the canonical format for a single locale: + +```typescript +const zh: AppTranslationBundle = { + _meta: { + locale: 'zh-CN', + direction: 'ltr', + }, + + // Object-first translations + o: { + account: { + label: 'ๅฎขๆˆท', + pluralLabel: 'ๅฎขๆˆท', + description: 'ๅฎขๆˆท็ฎก็†ๅฏน่ฑก', + fields: { + name: { label: 'ๅฎขๆˆทๅ็งฐ', help: 'ๅ…ฌๅธๆˆ–็ป„็ป‡็š„ๆณ•ๅฎšๅ็งฐ' }, + industry: { + label: '่กŒไธš', + options: { tech: '็ง‘ๆŠ€', finance: '้‡‘่ž', retail: '้›ถๅ”ฎ' } + }, + website: { label: '็ฝ‘็ซ™', placeholder: '่พ“ๅ…ฅ็ฝ‘็ซ™ๅœฐๅ€' }, + }, + _options: { + status: { active: 'ๆดป่ทƒ', inactive: 'ๅœ็”จ' }, + }, + _views: { + all_accounts: { label: 'ๅ…จ้ƒจๅฎขๆˆท' }, + my_accounts: { label: 'ๆˆ‘็š„ๅฎขๆˆท' }, + }, + _sections: { + basic_info: { label: 'ๅŸบๆœฌไฟกๆฏ' }, + contact_info: { label: '่”็ณปๆ–นๅผ' }, + }, + _actions: { + convert_lead: { label: '่ฝฌๆข็บฟ็ดข', confirmMessage: '็กฎ่ฎค่ฝฌๆขไธบๅฎขๆˆท๏ผŸ' }, + merge: { label: 'ๅˆๅนถๅฎขๆˆท', confirmMessage: 'ๆญคๆ“ไฝœๆ— ๆณ•ๆ’ค้”€๏ผŒ็กฎ่ฎคๅˆๅนถ๏ผŸ' }, + }, + }, + }, + + // Global picklist options (not object-specific) + _globalOptions: { + currency: { usd: '็พŽๅ…ƒ', eur: 'ๆฌงๅ…ƒ', cny: 'ไบบๆฐ‘ๅธ' }, + }, + + // App-level translations + app: { + crm: { label: 'ๅฎขๆˆทๅ…ณ็ณป็ฎก็†', description: '็ฎก็†้”€ๅ”ฎๆต็จ‹' }, + helpdesk: { label: 'ๆœๅŠกๅฐ', description: 'ๅฎขๆˆทๆ”ฏๆŒ็ณป็ปŸ' }, + }, + + // Navigation menu + nav: { + home: '้ฆ–้กต', + settings: '่ฎพ็ฝฎ', + reports: 'ๆŠฅ่กจ', + admin: '็ฎก็†', + }, + + // Dashboard translations + dashboard: { + sales_overview: { label: '้”€ๅ”ฎๆฆ‚่งˆ', description: '้”€ๅ”ฎๆผๆ–—ไธŽ็›ฎๆ ‡' }, + }, + + // Report translations + reports: { + pipeline_report: { label: '็ฎก้“ๆŠฅ่กจ' }, + }, + + // Page translations + pages: { + landing: { title: 'ๆฌข่ฟŽ', description: 'ๅผ€ๅง‹ไฝฟ็”จ ObjectStack' }, + }, + + // UI messages (supports ICU MessageFormat if enabled) + messages: { + 'common.save': 'ไฟๅญ˜', + 'common.cancel': 'ๅ–ๆถˆ', + 'common.delete': 'ๅˆ ้™ค', + 'common.confirm': '็กฎ่ฎค', + 'validation.required': 'ๆญคๅญ—ๆฎตไธบๅฟ…ๅกซ้กน', + 'pagination.showing': 'ๆ˜พ็คบ {start} ๅˆฐ {end}๏ผŒๅ…ฑ {total} ๆก', + }, + + // Validation error messages + validationMessages: { + discount_limit: 'ๆŠ˜ๆ‰ฃไธ่ƒฝ่ถ…่ฟ‡40%', + end_date_after_start: '็ป“ๆŸๆ—ฅๆœŸๅฟ…้กปๆ™šไบŽๅผ€ๅง‹ๆ—ฅๆœŸ', + }, + + // Global notifications + notifications: { + record_created: { title: 'ๅˆ›ๅปบๆˆๅŠŸ', body: '่ฎฐๅฝ•ๅทฒๅˆ›ๅปบ' }, + }, + + // Global error messages + errors: { + 'ERR_NETWORK': '็ฝ‘็ปœ่ฟžๆŽฅๅคฑ่ดฅ', + 'ERR_PERMISSION': 'ๆƒ้™ไธ่ถณ', + }, +}; +``` + +--- + +## Object-Level Translation Structure + +### ObjectTranslationNode + +All translatable content for a single object is aggregated under `o.{object_name}`: + +```typescript +interface ObjectTranslationNode { + /** Singular label */ + label: string; + /** Plural label */ + pluralLabel?: string; + /** Description */ + description?: string; + /** Help text */ + helpText?: string; + + /** Field translations */ + fields?: Record; + + /** Object-scoped picklist options */ + _options?: Record>; + + /** View translations */ + _views?: Record; + + /** Section translations (form tabs/sections) */ + _sections?: Record; + + /** Action translations */ + _actions?: Record; + + /** Notification translations */ + _notifications?: Record; + + /** Error message translations */ + _errors?: Record; +} +``` + +### FieldTranslation + +```typescript +interface FieldTranslation { + /** Translated field label */ + label?: string; + /** Help text */ + help?: string; + /** Placeholder text for form inputs */ + placeholder?: string; + /** Option value โ†’ translated label map */ + options?: Record; +} +``` + +--- + +## Naming Conventions + +| Context | Convention | Example | +|:--------|:-----------|:--------| +| Locale codes | BCP-47 | `en`, `en-US`, `zh-CN`, `pt-BR` | +| Object keys in `o.*` | `snake_case` | `o.project_task`, `o.support_case` | +| Field keys | `snake_case` | `fields.first_name`, `fields.due_date` | +| Option values | lowercase | `options.status.in_progress` | +| Message keys | dot-separated | `common.save`, `validation.required` | + +> **Critical:** Object names and field keys in translation bundles **must** match the `snake_case` names defined in your Object and Field schemas. + +--- + +## Translation Coverage & Diff Detection + +### Coverage Analysis + +The `II18nService.getCoverage()` method compares a translation bundle against source metadata to detect: + +1. **Missing** โ€” Keys that exist in metadata but not in the translation bundle +2. **Redundant** โ€” Keys in the bundle that have no matching metadata +3. **Stale** โ€” Keys where the source metadata has changed since translation + +```typescript +const coverage = i18nService.getCoverage('zh-CN', 'account'); + +console.log(coverage); +// { +// locale: 'zh-CN', +// objectName: 'account', +// totalKeys: 120, +// translatedKeys: 105, +// missingKeys: 12, +// redundantKeys: 3, +// staleKeys: 0, +// coveragePercent: 87.5, +// items: [ +// { key: 'o.account.fields.website.label', status: 'missing', locale: 'zh-CN' }, +// ... +// ], +// breakdown: [ +// { group: 'fields', totalKeys: 45, translatedKeys: 40, coveragePercent: 88.9 }, +// { group: 'views', totalKeys: 8, translatedKeys: 8, coveragePercent: 100 }, +// ], +// } +``` + +### TranslationDiffItem + +```typescript +interface TranslationDiffItem { + /** Dot-path translation key */ + key: string; + /** Diff status: 'missing' | 'redundant' | 'stale' */ + status: 'missing' | 'redundant' | 'stale'; + /** Object name (if applicable) */ + objectName?: string; + /** Locale code */ + locale: string; + /** Hash of source metadata for stale detection */ + sourceHash?: string; + /** AI-suggested translation */ + aiSuggested?: string; + /** AI confidence score (0-1) */ + aiConfidence?: number; +} +``` + +--- + +## AI-Powered Translation Suggestions + +### Using suggestTranslations() + +The `II18nService.suggestTranslations()` method enriches diff items with AI-generated translations: + +```typescript +const missingItems = coverage.items.filter(item => item.status === 'missing'); + +const suggestions = await i18nService.suggestTranslations('zh-CN', missingItems); + +suggestions.forEach(item => { + console.log(`${item.key}: ${item.aiSuggested} (confidence: ${item.aiConfidence})`); + // o.account.fields.website.label: ็ฝ‘็ซ™ (confidence: 0.95) +}); +``` + +> **Best Practice:** AI suggestions work best when: +> - You provide source locale context (e.g., English labels) +> - You include domain-specific glossaries +> - You review and approve suggestions before committing + +--- + +## Message Interpolation + +### Simple Format (Default) + +Use `{variable}` placeholders: + +```json +{ + "messages": { + "welcome": "Welcome, {userName}!", + "pagination": "Showing {start} to {end} of {total} items" + } +} +``` + +Usage: +```typescript +i18n.t('messages.welcome', 'en', { userName: 'Alice' }); +// "Welcome, Alice!" +``` + +### ICU MessageFormat + +For complex pluralization, gender, and select: + +```typescript +// Enable in stack config +i18n: { messageFormat: 'icu' } +``` + +```json +{ + "messages": { + "inbox": "{count, plural, =0 {No messages} one {1 message} other {# messages}}", + "gender": "{gender, select, male {He} female {She} other {They}} replied" + } +} +``` + +> **When to use ICU:** +> - Languages with complex plural rules (Arabic, Slavic languages) +> - Gender-aware translations +> - Ordinal numbers (1st, 2nd, 3rd) +> - Date/time/number formatting + +--- + +## Integration with II18nService + +### Service Contract + +```typescript +interface II18nService { + /** Translate a key */ + t(key: string, locale: string, params?: Record): string; + + /** Get all translations for a locale */ + getTranslations(locale: string): Record; + + /** Load translations */ + loadTranslations(locale: string, translations: Record): void; + + /** List available locales */ + getLocales(): string[]; + + /** Get default locale */ + getDefaultLocale?(): string; + + /** Set default locale */ + setDefaultLocale?(locale: string): void; + + /** Get object-first bundle */ + getAppBundle?(locale: string): AppTranslationBundle | undefined; + + /** Load object-first bundle */ + loadAppBundle?(locale: string, bundle: AppTranslationBundle): void; + + /** Get coverage analysis */ + getCoverage?(locale: string, objectName?: string): TranslationCoverageResult; + + /** AI-powered suggestions */ + suggestTranslations?( + locale: string, + items: TranslationDiffItem[] + ): Promise; +} +``` + +### Plugin Setup + +```typescript +import { ObjectKernel } from '@objectstack/core'; +import { I18nServicePlugin } from '@objectstack/service-i18n'; + +const kernel = new ObjectKernel(); +kernel.use(new I18nServicePlugin({ + defaultLocale: 'en', + localesDir: './i18n', + fallbackLocale: 'en', + registerRoutes: true, // Auto-register REST endpoints + basePath: '/api/v1/i18n', +})); + +await kernel.bootstrap(); + +const i18n = kernel.getService('i18n'); +``` + +--- + +## Translation Workflow Best Practices + +### 1. Extract Keys from Metadata + +Use the CLI or API to extract all translatable keys from your metadata: + +```bash +objectstack i18n extract --locale zh-CN --output i18n/zh-CN.json +``` + +This generates a skeleton bundle with all required keys. + +### 2. Translate + +Fill in the translations manually, or use AI suggestions: + +```bash +objectstack i18n suggest --locale zh-CN --input i18n/zh-CN.json +``` + +### 3. Validate Coverage + +Run coverage analysis to detect missing or stale translations: + +```bash +objectstack i18n coverage --locale zh-CN +``` + +### 4. Commit & Deploy + +Commit translation files to version control. ObjectStack automatically loads them at runtime. + +--- + +## Advanced Patterns + +### Namespace Isolation (Multi-Plugin) + +When multiple plugins contribute translations, use namespaces to avoid collisions: + +```typescript +const crmBundle: AppTranslationBundle = { + namespace: 'crm', + o: { + account: { label: 'ๅฎขๆˆท' }, + }, +}; + +const helpdeskBundle: AppTranslationBundle = { + namespace: 'helpdesk', + o: { + ticket: { label: 'ๅทฅๅ•' }, + }, +}; +``` + +Keys are prefixed: `crm.o.account.label`, `helpdesk.o.ticket.label`. + +### Right-to-Left (RTL) Support + +```typescript +const ar: AppTranslationBundle = { + _meta: { + locale: 'ar', + direction: 'rtl', + }, + o: { + account: { label: 'ุญุณุงุจ' }, + }, +}; +``` + +UI frameworks can use `_meta.direction` to apply RTL CSS. + +### Translation Memory Integration + +Implement custom `II18nService.suggestTranslations()` to integrate with: +- Translation Management Systems (TMS) like Phrase, Crowdin, Lokalise +- Machine translation APIs (Google Translate, DeepL) +- Internal translation memory databases + +--- + +## Common Pitfalls + +### โŒ Mismatched Object Names + +Translation keys must match metadata exactly: + +```typescript +// Metadata +{ name: 'project_task' } + +// Translation (WRONG) +{ o: { projectTask: { label: '้กน็›ฎไปปๅŠก' } } } + +// Translation (CORRECT) +{ o: { project_task: { label: '้กน็›ฎไปปๅŠก' } } } +``` + +### โŒ Hardcoded Option Values + +Always use lowercase machine values for options: + +```typescript +// Metadata +options: [ + { value: 'in_progress', label: 'In Progress' }, +] + +// Translation (WRONG) +options: { 'In Progress': '่ฟ›่กŒไธญ' } + +// Translation (CORRECT) +options: { in_progress: '่ฟ›่กŒไธญ' } +``` + +### โŒ Ignoring Coverage Reports + +Stale translations can cause confusion. Always run coverage analysis before releases. + +--- + +## Quick-Start Template + +```typescript +// i18n/zh-CN.ts +import type { AppTranslationBundle } from '@objectstack/spec'; + +export default { + _meta: { + locale: 'zh-CN', + direction: 'ltr', + }, + + o: { + account: { + label: 'ๅฎขๆˆท', + pluralLabel: 'ๅฎขๆˆท', + fields: { + name: { label: 'ๅฎขๆˆทๅ็งฐ' }, + email: { label: '้‚ฎ็ฎฑ', placeholder: '่พ“ๅ…ฅ้‚ฎ็ฎฑๅœฐๅ€' }, + status: { + label: '็Šถๆ€', + options: { + active: 'ๆดป่ทƒ', + inactive: 'ๅœ็”จ', + }, + }, + }, + _views: { + all_accounts: { label: 'ๅ…จ้ƒจๅฎขๆˆท' }, + }, + }, + }, + + app: { + crm: { label: 'ๅฎขๆˆทๅ…ณ็ณป็ฎก็†' }, + }, + + nav: { + home: '้ฆ–้กต', + settings: '่ฎพ็ฝฎ', + }, + + messages: { + 'common.save': 'ไฟๅญ˜', + 'common.cancel': 'ๅ–ๆถˆ', + }, +} satisfies AppTranslationBundle; +``` + +--- + +## References + +- [translation.zod.ts](./references/system/translation.zod.ts) โ€” Translation schemas (AppTranslationBundle, ObjectTranslationNode, Coverage, Diff) +- [i18n-service.ts](./references/contracts/i18n-service.ts) โ€” II18nService interface contract +- [i18n.zod.ts](./references/ui/i18n.zod.ts) โ€” UI-level i18n object schema +- [Schema index](./references/_index.md) โ€” All bundled schemas + +--- + +## See Also + +- **objectstack-data** โ€” For understanding object and field metadata structure +- **objectstack-ui** โ€” For view, app, and action translations +- **objectstack-automation** โ€” For workflow and flow message translations diff --git a/skills/objectstack-i18n/references/_index.md b/skills/objectstack-i18n/references/_index.md new file mode 100644 index 000000000..f3487c7a7 --- /dev/null +++ b/skills/objectstack-i18n/references/_index.md @@ -0,0 +1,19 @@ +# objectstack-i18n โ€” Zod Schema Reference + +> **Auto-generated** by `build-skill-references.ts`. +> These files are copied from `packages/spec/src/` for AI agent consumption. +> Do not edit โ€” re-run `pnpm --filter @objectstack/spec run gen:skill-refs` to update. + +## Core Schemas + +- [`system/translation.zod.ts`](./system/translation.zod.ts) โ€” Translation schemas: AppTranslationBundle, ObjectTranslationNode, TranslationConfig, TranslationCoverageResult, TranslationDiffItem +- [`contracts/i18n-service.ts`](./contracts/i18n-service.ts) โ€” II18nService interface contract +- [`ui/i18n.zod.ts`](./ui/i18n.zod.ts) โ€” UI-level i18n object schema + +## Dependencies (auto-resolved) + +These schemas are included in the references for completeness but are primarily managed by other skills: + +- **objectstack-data** โ€” Object, Field schemas (source for translation extraction) +- **objectstack-ui** โ€” View, App, Dashboard schemas (UI translation sources) +- **objectstack-automation** โ€” Flow, Workflow schemas (automation message translations) diff --git a/skills/objectstack-i18n/references/contracts/i18n-service.ts b/skills/objectstack-i18n/references/contracts/i18n-service.ts new file mode 100644 index 000000000..4ed79b7c9 --- /dev/null +++ b/skills/objectstack-i18n/references/contracts/i18n-service.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { AppTranslationBundle, TranslationCoverageResult, TranslationDiffItem } from '../system/translation.zod'; + +/** + * II18nService - Internationalization Service Contract + * + * Defines the interface for translation and locale management in ObjectStack. + * Concrete implementations (i18next, custom, etc.) + * should implement this interface. + * + * Follows Dependency Inversion Principle - plugins depend on this interface, + * not on concrete i18n library implementations. + * + * Aligned with CoreServiceName 'i18n' in core-services.zod.ts. + */ + +export interface II18nService { + /** + * Translate a message key for a given locale + * @param key - Translation key (e.g. 'o.account.label') + * @param locale - BCP-47 locale code (e.g. 'en-US', 'zh-CN') + * @param params - Optional interpolation parameters + * @returns Translated string, or the key itself if not found + */ + t(key: string, locale: string, params?: Record): string; + + /** + * Get all translations for a locale + * @param locale - BCP-47 locale code + * @returns Translation data map + */ + getTranslations(locale: string): Record; + + /** + * Load translations for a locale + * @param locale - BCP-47 locale code + * @param translations - Translation key-value data + */ + loadTranslations(locale: string, translations: Record): void; + + /** + * List available locales + * @returns Array of BCP-47 locale codes + */ + getLocales(): string[]; + + /** + * Get the current default locale + * @returns BCP-47 locale code + */ + getDefaultLocale?(): string; + + /** + * Set the default locale + * @param locale - BCP-47 locale code + */ + setDefaultLocale?(locale: string): void; + + // โ”€โ”€ Object-first aggregation & diff detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /** + * Get object-first translation bundle for a locale. + * + * Returns all translations aggregated under `o.{objectName}` with + * global groups (app, nav, dashboard, etc.) at the top level. + * + * @param locale - BCP-47 locale code + * @returns Object-first AppTranslationBundle, or undefined if no data + */ + getAppBundle?(locale: string): AppTranslationBundle | undefined; + + /** + * Load an object-first translation bundle for a locale. + * + * @param locale - BCP-47 locale code + * @param bundle - Object-first AppTranslationBundle + */ + loadAppBundle?(locale: string, bundle: AppTranslationBundle): void; + + /** + * Get translation coverage for a locale, optionally scoped to a single object. + * + * Compares the supplied (or currently loaded) translation bundle against + * the source metadata to detect missing, redundant, and stale entries. + * + * @param locale - BCP-47 locale code + * @param objectName - Optional object name to scope the check + * @returns Coverage result with per-key diff items + */ + getCoverage?(locale: string, objectName?: string): TranslationCoverageResult; + + /** + * Request AI-powered translation suggestions for missing or stale keys. + * + * Implementations may call an internal AI agent, external TMS, or + * third-party translation API. Each returned diff item should have + * `aiSuggested` and `aiConfidence` populated. + * + * @param locale - Target BCP-47 locale code + * @param items - Diff items to generate suggestions for + * @returns Diff items enriched with `aiSuggested` and `aiConfidence` + */ + suggestTranslations?(locale: string, items: TranslationDiffItem[]): Promise; +} diff --git a/skills/objectstack-i18n/references/system/translation.zod.ts b/skills/objectstack-i18n/references/system/translation.zod.ts new file mode 100644 index 000000000..7b23b16aa --- /dev/null +++ b/skills/objectstack-i18n/references/system/translation.zod.ts @@ -0,0 +1,541 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Locale +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const LocaleSchema = z.string().describe('BCP-47 Language Tag (e.g. en-US, zh-CN)'); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Object-level Translation (per-object file) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Field Translation Schema + * Translation data for a single field. + */ +export const FieldTranslationSchema = z.object({ + label: z.string().optional().describe('Translated field label'), + help: z.string().optional().describe('Translated help text'), + placeholder: z.string().optional().describe('Translated placeholder text for form inputs'), + options: z.record(z.string(), z.string()).optional().describe('Option value to translated label map'), +}).describe('Translation data for a single field'); + +export type FieldTranslation = z.infer; + +/** + * Object Translation Data Schema + * + * Translation data for a **single object** in a **single locale**. + * Use this schema to validate per-object translation files. + * + * File convention: `i18n/{locale}/{object_name}.json` + * + * @example + * ```json + * // i18n/en/account.json + * { + * "label": "Account", + * "pluralLabel": "Accounts", + * "fields": { + * "name": { "label": "Account Name", "help": "Legal name" }, + * "type": { "label": "Type", "options": { "customer": "Customer" } } + * } + * } + * ``` + */ +export const ObjectTranslationDataSchema = z.object({ + /** Translated singular label for the object */ + label: z.string().describe('Translated singular label'), + /** Translated plural label for the object */ + pluralLabel: z.string().optional().describe('Translated plural label'), + /** Field-level translations keyed by field name (snake_case) */ + fields: z.record(z.string(), FieldTranslationSchema).optional().describe('Field-level translations'), +}).describe('Translation data for a single object'); + +export type ObjectTranslationData = z.infer; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Locale-level Translation Data (per-locale aggregate) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Translation Data Schema + * Supports i18n for labels, messages, and options within a single locale. + * Example structure: + * ```json + * { + * "objects": { "account": { "label": "Account" } }, + * "apps": { "crm": { "label": "CRM" } }, + * "messages": { "common.save": "Save" } + * } + * ``` + */ +export const TranslationDataSchema = z.object({ + /** Object translations */ + objects: z.record(z.string(), ObjectTranslationDataSchema).optional().describe('Object translations keyed by object name'), + + /** App/Menu translations */ + apps: z.record(z.string(), z.object({ + label: z.string().describe('Translated app label'), + description: z.string().optional().describe('Translated app description'), + })).optional().describe('App translations keyed by app name'), + + /** UI Messages */ + messages: z.record(z.string(), z.string()).optional().describe('UI message translations keyed by message ID'), + + /** Validation Error Messages */ + validationMessages: z.record(z.string(), z.string()).optional().describe('Translatable validation error messages keyed by rule name (e.g., {"discount_limit": "ๆŠ˜ๆ‰ฃไธ่ƒฝ่ถ…่ฟ‡40%"})'), +}).describe('Translation data for objects, apps, and UI messages'); + +export type TranslationData = z.infer; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Translation Bundle (all locales) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const TranslationBundleSchema = z.record(LocaleSchema, TranslationDataSchema).describe('Map of locale codes to translation data'); + +export type TranslationBundle = z.infer; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// File Organization Convention +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Translation File Organization Strategy + * + * Defines how translation files are organized on disk. + * + * - `bundled` โ€” All locales in a single `TranslationBundle` file. + * Best for small projects with few objects. + * ``` + * src/translations/ + * crm.translation.ts # { en: {...}, "zh-CN": {...} } + * ``` + * + * - `per_locale` โ€” One file per locale containing all namespaces. + * Recommended when a single locale file stays under ~500 lines. + * ``` + * src/translations/ + * en.ts # TranslationData for English + * zh-CN.ts # TranslationData for Chinese + * ``` + * + * - `per_namespace` โ€” One file per namespace (object) per locale. + * Recommended for large projects with many objects/languages. + * Aligns with Salesforce DX and ServiceNow conventions. + * ``` + * i18n/ + * en/ + * account.json # ObjectTranslationData + * contact.json + * common.json # messages + app labels + * zh-CN/ + * account.json + * contact.json + * common.json + * ``` + */ +export const TranslationFileOrganizationSchema = z.enum([ + 'bundled', + 'per_locale', + 'per_namespace', +]).describe('Translation file organization strategy'); + +export type TranslationFileOrganization = z.infer; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Translation Configuration +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Translation Configuration Schema + * + * Defines internationalization settings for the stack. + * + * @example + * ```typescript + * export default defineStack({ + * i18n: { + * defaultLocale: 'en', + * supportedLocales: ['en', 'zh-CN', 'ja-JP'], + * fallbackLocale: 'en', + * fileOrganization: 'per_locale', + * }, + * translations: [...], + * }); + * ``` + */ +/** + * Message format standard used for interpolation, pluralization, and + * gender-aware translations. + * + * - `icu` โ€” ICU MessageFormat (recommended for complex plurals, gender, select). + * Strings may contain `{count, plural, one {# item} other {# items}}` patterns. + * - `simple` โ€” Simple `{variable}` interpolation only (default). + */ +export const MessageFormatSchema = z.enum([ + 'icu', + 'simple', +]).describe('Message interpolation format: ICU MessageFormat or simple {variable} replacement'); + +export type MessageFormat = z.infer; + +export const TranslationConfigSchema = z.object({ + /** Default locale for the application */ + defaultLocale: LocaleSchema.describe('Default locale (e.g., "en")'), + /** Supported BCP-47 locale codes */ + supportedLocales: z.array(LocaleSchema).describe('Supported BCP-47 locale codes'), + /** Fallback locale when translation is not found */ + fallbackLocale: LocaleSchema.optional().describe('Fallback locale code'), + /** How translation files are organized on disk */ + fileOrganization: TranslationFileOrganizationSchema.default('per_locale') + .describe('File organization strategy'), + /** + * Message interpolation format. + * When set to `'icu'`, messages and validationMessages are expected to use + * ICU MessageFormat syntax (plurals, select, number/date skeletons). + * @default 'simple' + */ + messageFormat: MessageFormatSchema.default('simple') + .describe('Message interpolation format (ICU MessageFormat or simple)'), + /** Load translations on demand instead of eagerly */ + lazyLoad: z.boolean().default(false).describe('Load translations on demand'), + /** Cache loaded translations in memory */ + cache: z.boolean().default(true).describe('Cache loaded translations'), +}).describe('Internationalization configuration'); + +export type TranslationConfig = z.infer; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Object-First Translation Node (object-first aggregated structure) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Translatable option map: option value โ†’ translated label */ +const OptionTranslationMapSchema = z.record(z.string(), z.string()) + .describe('Option value to translated label map'); + +/** + * ObjectTranslationNodeSchema + * + * Object-first aggregated translation node that groups **all** translatable + * content for a single object under one key. Aligns with Salesforce / Dynamics + * conventions where translations are organized per-object rather than per-category. + * + * Located at `o.{object_name}` inside an {@link AppTranslationBundle}. + * + * @example + * ```typescript + * const accountNode: ObjectTranslationNode = { + * 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_lead: { label: '่ฝฌๆข็บฟ็ดข', confirmMessage: '็กฎ่ฎค่ฝฌๆข๏ผŸ' }, + * }, + * }; + * ``` + */ +export const ObjectTranslationNodeSchema = z.object({ + /** Translated singular label */ + label: z.string().describe('Translated singular label'), + /** Translated plural label */ + pluralLabel: z.string().optional().describe('Translated plural label'), + /** Translated object description */ + description: z.string().optional().describe('Translated object description'), + /** Translated help text shown in tooltips or guidance panels */ + helpText: z.string().optional().describe('Translated help text for the object'), + + /** Field-level translations keyed by field name (snake_case) */ + fields: z.record(z.string(), FieldTranslationSchema).optional() + .describe('Field translations keyed by field name'), + + /** + * Global picklist / select option overrides scoped to this object. + * Keyed by field name โ†’ { optionValue: translatedLabel }. + */ + _options: z.record(z.string(), OptionTranslationMapSchema).optional() + .describe('Object-scoped picklist option translations keyed by field name'), + + /** View translations keyed by view name */ + _views: z.record(z.string(), z.object({ + label: z.string().optional().describe('Translated view label'), + description: z.string().optional().describe('Translated view description'), + })).optional().describe('View translations keyed by view name'), + + /** Section (form section / tab) translations keyed by section name */ + _sections: z.record(z.string(), z.object({ + label: z.string().optional().describe('Translated section label'), + })).optional().describe('Section translations keyed by section name'), + + /** Action translations keyed by action name */ + _actions: z.record(z.string(), z.object({ + label: z.string().optional().describe('Translated action label'), + confirmMessage: z.string().optional().describe('Translated confirmation message'), + })).optional().describe('Action translations keyed by action name'), + + /** Notification message translations keyed by notification name */ + _notifications: z.record(z.string(), z.object({ + title: z.string().optional().describe('Translated notification title'), + body: z.string().optional().describe('Translated notification body (supports ICU MessageFormat when enabled)'), + })).optional().describe('Notification translations keyed by notification name'), + + /** Error message translations keyed by error code */ + _errors: z.record(z.string(), z.string()).optional() + .describe('Error message translations keyed by error code'), +}).describe('Object-first aggregated translation node'); + +export type ObjectTranslationNode = z.infer; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// App Translation Bundle (object-first, full application) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * AppTranslationBundleSchema + * + * Complete application translation bundle for a **single locale** using + * the **object-first** convention. All per-object translatable content + * is aggregated under `o.{object_name}`, while global (non-object-bound) + * translations are kept in dedicated top-level groups. + * + * This schema is designed for: + * - Translation workbench UIs (object-level editing & coverage) + * - CLI skeleton generation (`objectstack i18n extract`) + * - Automated diff/coverage detection + * + * @example + * ```typescript + * const zh: AppTranslationBundle = { + * o: { + * account: { + * label: 'ๅฎขๆˆท', + * fields: { name: { label: 'ๅฎขๆˆทๅ็งฐ' } }, + * _options: { industry: { tech: '็ง‘ๆŠ€' } }, + * _views: { all_accounts: { label: 'ๅ…จ้ƒจๅฎขๆˆท' } }, + * _sections: { basic_info: { label: 'ๅŸบๆœฌไฟกๆฏ' } }, + * _actions: { convert: { label: '่ฝฌๆข' } }, + * }, + * }, + * _globalOptions: { currency: { usd: '็พŽๅ…ƒ', eur: 'ๆฌงๅ…ƒ' } }, + * app: { crm: { label: 'ๅฎขๆˆทๅ…ณ็ณป็ฎก็†', description: '็ฎก็†้”€ๅ”ฎๆต็จ‹' } }, + * nav: { home: '้ฆ–้กต', settings: '่ฎพ็ฝฎ' }, + * dashboard: { sales_overview: { label: '้”€ๅ”ฎๆฆ‚่งˆ' } }, + * reports: { pipeline_report: { label: '็ฎก้“ๆŠฅ่กจ' } }, + * pages: { landing: { title: 'ๆฌข่ฟŽ' } }, + * messages: { 'common.save': 'ไฟๅญ˜' }, + * validationMessages: { 'discount_limit': 'ๆŠ˜ๆ‰ฃไธ่ƒฝ่ถ…่ฟ‡40%' }, + * }; + * ``` + */ +export const AppTranslationBundleSchema = z.object({ + /** + * Bundle-level metadata. + * Provides locale-aware rendering hints such as text direction (bidi) + * and the canonical locale code this bundle represents. + */ + _meta: z.object({ + /** BCP-47 locale code this bundle represents */ + locale: z.string().optional().describe('BCP-47 locale code for this bundle'), + /** Text direction for the locale */ + direction: z.enum(['ltr', 'rtl']).optional().describe('Text direction: left-to-right or right-to-left'), + }).optional().describe('Bundle-level metadata (locale, bidi direction)'), + + /** + * Namespace for plugin/extension isolation. + * When multiple plugins contribute translations, each should use a unique + * namespace to avoid key collisions (e.g. "crm", "helpdesk", "plugin-xyz"). + */ + namespace: z.string().optional() + .describe('Namespace for plugin isolation to avoid translation key collisions'), + + /** Object-first translations keyed by object name (snake_case) */ + o: z.record(z.string(), ObjectTranslationNodeSchema).optional() + .describe('Object-first translations keyed by object name'), + + /** Global picklist options not bound to any specific object */ + _globalOptions: z.record(z.string(), OptionTranslationMapSchema).optional() + .describe('Global picklist option translations keyed by option set name'), + + /** App-level translations */ + app: z.record(z.string(), z.object({ + label: z.string().describe('Translated app label'), + description: z.string().optional().describe('Translated app description'), + })).optional().describe('App translations keyed by app name'), + + /** Navigation menu translations */ + nav: z.record(z.string(), z.string()).optional() + .describe('Navigation item translations keyed by nav item name'), + + /** Dashboard translations keyed by dashboard name */ + dashboard: z.record(z.string(), z.object({ + label: z.string().optional().describe('Translated dashboard label'), + description: z.string().optional().describe('Translated dashboard description'), + })).optional().describe('Dashboard translations keyed by dashboard name'), + + /** Report translations keyed by report name */ + reports: z.record(z.string(), z.object({ + label: z.string().optional().describe('Translated report label'), + description: z.string().optional().describe('Translated report description'), + })).optional().describe('Report translations keyed by report name'), + + /** Page translations keyed by page name */ + pages: z.record(z.string(), z.object({ + title: z.string().optional().describe('Translated page title'), + description: z.string().optional().describe('Translated page description'), + })).optional().describe('Page translations keyed by page name'), + + /** UI message translations (supports ICU MessageFormat when enabled) */ + messages: z.record(z.string(), z.string()).optional() + .describe('UI message translations keyed by message ID (supports ICU MessageFormat)'), + + /** Validation error message translations (supports ICU MessageFormat when enabled) */ + validationMessages: z.record(z.string(), z.string()).optional() + .describe('Validation error message translations keyed by rule name (supports ICU MessageFormat)'), + + /** Global notification translations not bound to a specific object */ + notifications: z.record(z.string(), z.object({ + title: z.string().optional().describe('Translated notification title'), + body: z.string().optional().describe('Translated notification body (supports ICU MessageFormat when enabled)'), + })).optional().describe('Global notification translations keyed by notification name'), + + /** Global error message translations not bound to a specific object */ + errors: z.record(z.string(), z.string()).optional() + .describe('Global error message translations keyed by error code'), +}).describe('Object-first application translation bundle for a single locale'); + +export type AppTranslationBundle = z.infer; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Translation Diff & Coverage +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Translation Diff Status + * + * Status of a single translation entry compared to the source metadata. + */ +export const TranslationDiffStatusSchema = z.enum([ + 'missing', + 'redundant', + 'stale', +]).describe('Translation diff status: missing from bundle, redundant (no matching metadata), or stale (metadata changed)'); + +export type TranslationDiffStatus = z.infer; + +/** + * TranslationDiffItemSchema + * + * Describes a single translation key that is missing, redundant, or stale + * relative to the source metadata. Used by CLI/API diff detection. + * + * @example + * ```typescript + * const item: TranslationDiffItem = { + * key: 'o.account.fields.website.label', + * status: 'missing', + * objectName: 'account', + * locale: 'zh-CN', + * }; + * ``` + */ +export const TranslationDiffItemSchema = z.object({ + /** Dot-path translation key (e.g. "o.account.fields.website.label") */ + key: z.string().describe('Dot-path translation key'), + /** Diff status */ + status: TranslationDiffStatusSchema.describe('Diff status of this translation key'), + /** Object name if the key belongs to an object translation node */ + objectName: z.string().optional().describe('Associated object name (snake_case)'), + /** Locale code */ + locale: z.string().describe('BCP-47 locale code'), + /** + * Hash of the source metadata value at the time the translation was made. + * Used by CLI/Workbench to detect stale translations without a full diff. + */ + sourceHash: z.string().optional().describe('Hash of source metadata for precise stale detection'), + /** + * AI-suggested translation text for missing or stale entries. + * Populated by AI translation hooks or TMS integrations. + */ + aiSuggested: z.string().optional().describe('AI-suggested translation for this key'), + /** Confidence score (0-1) for the AI suggestion */ + aiConfidence: z.number().min(0).max(1).optional().describe('AI suggestion confidence score (0โ€“1)'), +}).describe('A single translation diff item'); + +export type TranslationDiffItem = z.infer; + +/** + * TranslationCoverageResultSchema + * + * Aggregated coverage result for a locale, optionally scoped to a single object. + * Returned by the i18n diff detection API. + * + * @example + * ```typescript + * const result: TranslationCoverageResult = { + * locale: 'zh-CN', + * totalKeys: 120, + * translatedKeys: 105, + * missingKeys: 12, + * redundantKeys: 3, + * staleKeys: 0, + * coveragePercent: 87.5, + * items: [ ... ], + * }; + * ``` + */ +/** + * Per-group coverage breakdown entry. + */ +export const CoverageBreakdownEntrySchema = z.object({ + /** Group category (e.g. "fields", "views", "actions", "messages") */ + group: z.string().describe('Translation group category'), + /** Total translatable keys in this group */ + totalKeys: z.number().int().nonnegative().describe('Total keys in this group'), + /** Number of translated keys in this group */ + translatedKeys: z.number().int().nonnegative().describe('Translated keys in this group'), + /** Coverage percentage for this group */ + coveragePercent: z.number().min(0).max(100).describe('Coverage percentage for this group'), +}).describe('Coverage breakdown for a single translation group'); + +export type CoverageBreakdownEntry = z.infer; + +export const TranslationCoverageResultSchema = z.object({ + /** BCP-47 locale code */ + locale: z.string().describe('BCP-47 locale code'), + /** Optional object name scope */ + objectName: z.string().optional().describe('Object name scope (omit for full bundle)'), + /** Total translatable keys derived from metadata */ + totalKeys: z.number().int().nonnegative().describe('Total translatable keys from metadata'), + /** Number of keys that have a translation */ + translatedKeys: z.number().int().nonnegative().describe('Number of translated keys'), + /** Number of missing translations */ + missingKeys: z.number().int().nonnegative().describe('Number of missing translations'), + /** Number of redundant (orphaned) translations */ + redundantKeys: z.number().int().nonnegative().describe('Number of redundant translations'), + /** Number of stale translations */ + staleKeys: z.number().int().nonnegative().describe('Number of stale translations'), + /** Coverage percentage (0-100) */ + coveragePercent: z.number().min(0).max(100).describe('Translation coverage percentage'), + /** Individual diff items */ + items: z.array(TranslationDiffItemSchema).describe('Detailed diff items'), + /** + * Per-group coverage breakdown for translation project management. + * Each entry represents a logical group (e.g. "fields", "views", "actions", + * "messages") with its own coverage statistics. + */ + breakdown: z.array(CoverageBreakdownEntrySchema).optional() + .describe('Per-group coverage breakdown'), +}).describe('Aggregated translation coverage result'); + +export type TranslationCoverageResult = z.infer; diff --git a/skills/objectstack-i18n/references/ui/i18n.zod.ts b/skills/objectstack-i18n/references/ui/i18n.zod.ts new file mode 100644 index 000000000..8e6f40d38 --- /dev/null +++ b/skills/objectstack-i18n/references/ui/i18n.zod.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * I18n Object Schema + * Structured internationalization label with translation key and parameters. + * + * @example + * ```typescript + * const label: I18nObject = { + * key: 'views.task_list.label', + * defaultValue: 'Task List', + * params: { count: 5 }, + * }; + * ``` + */ +export const I18nObjectSchema = z.object({ + /** Translation key (e.g., "views.task_list.label", "apps.crm.description") */ + key: z.string().describe('Translation key (e.g., "views.task_list.label")'), + + /** Default value when translation is not available */ + defaultValue: z.string().optional().describe('Fallback value when translation key is not found'), + + /** Interpolation parameters for dynamic translations */ + params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional().describe('Interpolation parameters (e.g., { count: 5 })'), +}); + +export type I18nObject = z.infer; + +/** + * I18n Label Schema + * + * A plain string label for display purposes. + * i18n translation keys are auto-generated by the framework at registration time + * based on a standardized naming convention (e.g., `apps...label`). + * Developers only need to provide the default-language string; translations are + * managed through translation files, not inline i18n objects. + * + * @example + * ```typescript + * const label: I18nLabel = "All Active"; + * ``` + */ +export const I18nLabelSchema = z.string().describe('Display label (plain string; i18n keys are auto-generated by the framework)'); + +export type I18nLabel = z.infer; + +/** + * ARIA Accessibility Properties Schema + * + * Common ARIA attributes for UI components to support screen readers + * and assistive technologies. + * + * Aligned with WAI-ARIA 1.2 specification. + * + * @see https://www.w3.org/TR/wai-aria-1.2/ + * + * @example + * ```typescript + * const aria: AriaProps = { + * ariaLabel: 'Close dialog', + * ariaDescribedBy: 'dialog-description', + * role: 'dialog', + * }; + * ``` + */ +export const AriaPropsSchema = z.object({ + /** Accessible label for screen readers */ + ariaLabel: I18nLabelSchema.optional().describe('Accessible label for screen readers (WAI-ARIA aria-label)'), + + /** ID of element that describes this component */ + ariaDescribedBy: z.string().optional().describe('ID of element providing additional description (WAI-ARIA aria-describedby)'), + + /** WAI-ARIA role override */ + role: z.string().optional().describe('WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert")'), +}).describe('ARIA accessibility attributes'); + +export type AriaProps = z.infer; + +/** + * Plural Rule Schema + * + * Defines plural forms for a translation key, following ICU MessageFormat / i18next conventions. + * Supports zero, one, two, few, many, other forms per CLDR plural rules. + * + * @see https://unicode.org/reports/tr35/tr35-numbers.html#Language_Plural_Rules + * + * @example + * ```typescript + * const plural: PluralRule = { + * key: 'items.count', + * zero: 'No items', + * one: '{count} item', + * other: '{count} items', + * }; + * ``` + */ +export const PluralRuleSchema = z.object({ + /** Translation key for the plural form */ + key: z.string().describe('Translation key'), + /** Form for zero quantity */ + zero: z.string().optional().describe('Zero form (e.g., "No items")'), + /** Form for singular (1) */ + one: z.string().optional().describe('Singular form (e.g., "{count} item")'), + /** Form for dual (2) โ€” used in Arabic, Welsh, etc. */ + two: z.string().optional().describe('Dual form (e.g., "{count} items" for exactly 2)'), + /** Form for few (2-4 in Slavic languages) */ + few: z.string().optional().describe('Few form (e.g., for 2-4 in some languages)'), + /** Form for many (5+ in Slavic languages) */ + many: z.string().optional().describe('Many form (e.g., for 5+ in some languages)'), + /** Default/fallback form */ + other: z.string().describe('Default plural form (e.g., "{count} items")'), +}).describe('ICU plural rules for a translation key'); + +export type PluralRule = z.infer; + +/** + * Number Format Schema + * + * Defines number formatting rules for localization. + * + * @example + * ```typescript + * const format: NumberFormat = { + * style: 'currency', + * currency: 'USD', + * minimumFractionDigits: 2, + * }; + * ``` + */ +export const NumberFormatSchema = z.object({ + style: z.enum(['decimal', 'currency', 'percent', 'unit']).default('decimal') + .describe('Number formatting style'), + currency: z.string().optional().describe('ISO 4217 currency code (e.g., "USD", "EUR")'), + unit: z.string().optional().describe('Unit for unit formatting (e.g., "kilometer", "liter")'), + minimumFractionDigits: z.number().optional().describe('Minimum number of fraction digits'), + maximumFractionDigits: z.number().optional().describe('Maximum number of fraction digits'), + useGrouping: z.boolean().optional().describe('Whether to use grouping separators (e.g., 1,000)'), +}).describe('Number formatting rules'); + +export type NumberFormat = z.infer; + +/** + * Date Format Schema + * + * Defines date/time formatting rules for localization. + * + * @example + * ```typescript + * const format: DateFormat = { + * dateStyle: 'medium', + * timeStyle: 'short', + * timeZone: 'America/New_York', + * }; + * ``` + */ +export const DateFormatSchema = z.object({ + dateStyle: z.enum(['full', 'long', 'medium', 'short']).optional() + .describe('Date display style'), + timeStyle: z.enum(['full', 'long', 'medium', 'short']).optional() + .describe('Time display style'), + timeZone: z.string().optional().describe('IANA time zone (e.g., "America/New_York")'), + hour12: z.boolean().optional().describe('Use 12-hour format'), +}).describe('Date/time formatting rules'); + +export type DateFormat = z.infer; + +/** + * Locale Configuration Schema + * + * Defines a complete locale configuration including language code, + * fallback chain, and formatting preferences. + * + * @example + * ```typescript + * const locale: LocaleConfig = { + * code: 'zh-CN', + * fallbackChain: ['zh-TW', 'en'], + * direction: 'ltr', + * numberFormat: { style: 'decimal', useGrouping: true }, + * dateFormat: { dateStyle: 'medium', timeStyle: 'short' }, + * }; + * ``` + */ +export const LocaleConfigSchema = z.object({ + /** BCP 47 language code (e.g., "en-US", "zh-CN", "ar-SA") */ + code: z.string().describe('BCP 47 language code (e.g., "en-US", "zh-CN")'), + + /** Ordered fallback chain for missing translations */ + fallbackChain: z.array(z.string()).optional() + .describe('Fallback language codes in priority order (e.g., ["zh-TW", "en"])'), + + /** Text direction */ + direction: z.enum(['ltr', 'rtl']).default('ltr') + .describe('Text direction: left-to-right or right-to-left'), + + /** Default number formatting */ + numberFormat: NumberFormatSchema.optional().describe('Default number formatting rules'), + + /** Default date formatting */ + dateFormat: DateFormatSchema.optional().describe('Default date/time formatting rules'), +}).describe('Locale configuration'); + +export type LocaleConfig = z.infer;