Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…ma, AppTranslationBundleSchema, diff/coverage schemas, and extended II18nService contract Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
There was a problem hiding this comment.
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 intranslation.zod.ts. - Extended
II18nServicewith 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. |
| /** 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'), |
There was a problem hiding this comment.
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).
| 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'); | ||
|
|
There was a problem hiding this comment.
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.
| 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'); |
| * Compares the supplied (or currently loaded) translation bundle against | ||
| * the source metadata to detect missing, redundant, and stale entries. |
There was a problem hiding this comment.
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.
| * 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. |
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,helpTextAppTranslationBundleSchema— single-locale app bundle witho.{object}root + global groups (_globalOptions,app,nav,dashboard,reports,pages,messages,validationMessages)TranslationDiffItemSchema/TranslationCoverageResultSchema— per-key diff detection for CLI/API coverage reportingExtended contract (
i18n-service.ts)Three optional methods on
II18nService(backward-compatible):getAppBundle(locale)/loadAppBundle(locale, bundle)— object-first bundle I/OgetCoverage(locale, objectName?)— returns missing/redundant/stale items with coverage %Example
Docs & tests
i18n-standard.mdx— added object-first convention sectionROADMAP.md— updated translation referencesOriginal prompt
💡 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.