Skip to content

feat: object-first i18n convention (ObjectTranslationNode, AppTranslationBundle, diff/coverage schemas)#846

Merged
hotlong merged 2 commits intomainfrom
copilot/add-object-first-internationalization
Mar 1, 2026
Merged

feat: object-first i18n convention (ObjectTranslationNode, AppTranslationBundle, diff/coverage schemas)#846
hotlong merged 2 commits intomainfrom
copilot/add-object-first-internationalization

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 1, 2026

Current i18n keys use category-first flat structure, causing redundancy and poor workbench/detection ergonomics. Upgrades to object-first aggregation aligned with Salesforce DX / Dynamics conventions.

New schemas (translation.zod.ts)

  • ObjectTranslationNodeSchema — all translatable content for one object: label, fields, _options, _views, _sections, _actions, description, helpText
  • AppTranslationBundleSchema — single-locale app bundle with o.{object} root + global groups (_globalOptions, app, nav, dashboard, reports, pages, messages, validationMessages)
  • TranslationDiffItemSchema / TranslationCoverageResultSchema — per-key diff detection for CLI/API coverage reporting

Extended contract (i18n-service.ts)

Three optional methods on II18nService (backward-compatible):

  • getAppBundle(locale) / loadAppBundle(locale, bundle) — object-first bundle I/O
  • getCoverage(locale, objectName?) — returns missing/redundant/stale items with coverage %

Example

const zh: AppTranslationBundle = {
  o: {
    account: {
      label: '客户',
      fields: { name: { label: '客户名称' } },
      _options: { status: { active: '活跃' } },
      _views: { all_accounts: { label: '全部客户' } },
      _sections: { basic_info: { label: '基本信息' } },
      _actions: { convert: { label: '转换', confirmMessage: '确认?' } },
    },
  },
  _globalOptions: { currency: { usd: '美元' } },
  app: { crm: { label: '客户关系管理' } },
  nav: { home: '首页' },
  messages: { 'common.save': '保存' },
};

Docs & tests

  • i18n-standard.mdx — added object-first convention section
  • ROADMAP.md — updated translation references
  • 22 new tests (6592 total, all green)
Original prompt

This section details on the original issue you should resolve

<issue_title>强化元数据国际化约定(对象优先/全局分离),为自动检测和工作台优化结构</issue_title>
<issue_description>### 背景
当前的元数据翻译 key 采用 category-first(按类别分散)结构,翻译覆盖但实际渲染/维护体验较差,且冗余严重。Salesforce、Dynamics 等主流平台采用以对象为单位聚合的国际化元数据布局,大幅提升翻译工作台和运行时自动检测能力。

方案 (Spec 协议和 API Runtime 优化)

1. 新增对象优先的国际化规范协议

将 category-first flat key 约定升级为 object-first,嵌套所有对象可翻译内容,见下方结构:

const zh = {
  o: {
    account: {
      label: '客户',
      description: '......',
      _options: { industry: { ... } },
      _views: { ... },
      _sections: { ... },
      _actions: { ... },
      // 预留 helpText/placeholder/pluralLabel
    },
    ...
  },
  _globalOptions: { ... },
  dashboard: { ... },
  pages: { ... },
};
  • o.{object}为根,将label/description, field, picklist, view, section, action(及params)等所有相关翻译聚合。
  • category/fieldOptions/reports/dashboard.columns冗余节点将被移除,字段与选项复用。
  • 全局(非对象绑定)翻译保持原有分组(app,nav,dashboard,reports,pages等)。

更新协议文档与 Zod Schema :

  • ui/i18n.zod.ts 新增 ObjectTranslationNodeSchema/AppTranslationBundleSchema、更新国际化约定 markdown 说明文档
  • contracts/i18n-service.ts 扩展国际化服务接口支持以object级别聚合生成、diff检测

2. API Runtime与CLI/Workbench约定升级

  • 新增API: 输入完整metadata与locale bundle可返回所有缺失、冗余、陈旧翻译项(支持object维度聚合结果)
  • CLI工具自动检测、导出object优先的翻译骨架
  • 文档与测试覆盖调整

验收标准

  • 协议与示例schema同步
  • 完整的object-first结构生成/检测能力
  • API/CLI/Workbench三处完全一致的约定兼容
  • 测试与ROADMAP文档同步

如需协作/CLI采样脚本/Bench交互设计可随时跟进补充。</issue_description>

Comments on the Issue (you are @copilot in this section)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectstack-play Ready Ready Preview, Comment Mar 1, 2026 4:12pm
spec Ready Ready Preview, Comment Mar 1, 2026 4:12pm

Request Review

…ma, AppTranslationBundleSchema, diff/coverage schemas, and extended II18nService contract

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Add object-first internationalization specification feat: object-first i18n convention (ObjectTranslationNode, AppTranslationBundle, diff/coverage schemas) Mar 1, 2026
@hotlong hotlong marked this pull request as ready for review March 1, 2026 16:39
Copilot AI review requested due to automatic review settings March 1, 2026 16:39
@hotlong hotlong merged commit 47e03a7 into main Mar 1, 2026
3 checks passed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces an object-first i18n convention into @objectstack/spec to reduce translation-key redundancy and enable better workbench ergonomics plus automated diff/coverage reporting.

Changes:

  • Added object-first translation bundle schemas (ObjectTranslationNode, AppTranslationBundle) and diff/coverage result schemas in translation.zod.ts.
  • Extended II18nService with optional object-first bundle I/O and coverage reporting methods.
  • Added/updated tests and docs to reflect the new object-first convention, plus ROADMAP references.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/spec/src/system/translation.zod.ts Adds Zod schemas/types for object-first bundles and diff/coverage reporting.
packages/spec/src/system/translation.test.ts Adds parsing/validation tests for the new i18n schemas.
packages/spec/src/contracts/i18n-service.ts Extends the i18n service contract with optional object-first bundle + coverage methods.
packages/spec/src/contracts/i18n-service.test.ts Adds contract-typing tests for the new optional methods.
content/docs/protocol/objectos/i18n-standard.mdx Documents the recommended object-first bundle structure.
ROADMAP.md Updates roadmap references to mention object-first translation bundle + diff/coverage detection.

Comment on lines +235 to +244
/** 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'),
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ObjectTranslationNodeSchema exposes option translations in two places: fields.{field}.options (via FieldTranslationSchema) and _options.{field}. Without a defined precedence rule, the same field’s options could be duplicated or conflict, which makes diff/coverage generation ambiguous. Consider choosing a single canonical location for object-scoped option translations (or explicitly documenting and enforcing precedence via schema/docs).

Copilot uses AI. Check for mistakes.
Comment on lines +421 to +441
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'),
}).describe('Aggregated translation coverage result');

Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TranslationCoverageResultSchema validates each numeric field independently, but it currently allows internally inconsistent results (e.g., translatedKeys > totalKeys, missingKeys + translatedKeys !== totalKeys, or a coveragePercent that doesn’t match the counts). Since this schema is meant for CLI/API reporting, consider adding cross-field validation (or deriving coveragePercent) to guarantee coherent outputs.

Suggested change
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'),
}).describe('Aggregated translation coverage result');
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'),
})
.superRefine((value, ctx) => {
const {
totalKeys,
translatedKeys,
missingKeys,
redundantKeys,
staleKeys,
coveragePercent,
} = value;
// Basic relational constraints against totalKeys
if (translatedKeys > totalKeys) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['translatedKeys'],
message: 'translatedKeys cannot be greater than totalKeys',
});
}
if (missingKeys > totalKeys) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['missingKeys'],
message: 'missingKeys cannot be greater than totalKeys',
});
}
// Redundant and stale keys are logically bounded by totalKeys as well
if (redundantKeys > totalKeys) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['redundantKeys'],
message: 'redundantKeys cannot be greater than totalKeys',
});
}
if (staleKeys > totalKeys) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['staleKeys'],
message: 'staleKeys cannot be greater than totalKeys',
});
}
// Relationship between totalKeys, translatedKeys, and missingKeys
if (totalKeys === 0) {
// When there are no translatable keys, counts must all be zero.
if (translatedKeys !== 0 || missingKeys !== 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['totalKeys'],
message:
'When totalKeys is 0, translatedKeys and missingKeys must also be 0',
});
}
// coveragePercent is undefined from counts when totalKeys === 0.
// We intentionally do not enforce a specific convention here.
} else {
const expectedTotal = translatedKeys + missingKeys;
if (expectedTotal !== totalKeys) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['totalKeys'],
message:
'totalKeys must equal translatedKeys + missingKeys when totalKeys > 0',
});
}
// Validate coveragePercent against the computed ratio with a small tolerance
const expectedCoverage = (translatedKeys / totalKeys) * 100;
const normalize = (n: number) => Math.round(n * 1000) / 1000; // 3 decimal places
const normalizedActual = normalize(coveragePercent);
const normalizedExpected = normalize(expectedCoverage);
if (Math.abs(normalizedActual - normalizedExpected) > 0.001) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['coveragePercent'],
message:
'coveragePercent must match translatedKeys / totalKeys * 100 (within rounding tolerance)',
});
}
}
})
.describe('Aggregated translation coverage result');

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +85
* Compares the supplied (or currently loaded) translation bundle against
* the source metadata to detect missing, redundant, and stale entries.
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getCoverage doc says it compares a “supplied (or currently loaded) translation bundle” against metadata, but the method signature only accepts (locale, objectName?) and provides no way to pass a bundle. Please either adjust the JSDoc to match the API, or change the signature to accept an explicit bundle/source so implementations are unambiguous.

Suggested change
* Compares the supplied (or currently loaded) translation bundle against
* the source metadata to detect missing, redundant, and stale entries.
* Compares the currently loaded translations (or object-first app bundle) for
* the locale against the source metadata to detect missing, redundant, and
* stale entries.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

强化元数据国际化约定(对象优先/全局分离),为自动检测和工作台优化结构

3 participants