Skip to content

Commit 88de57a

Browse files
Copilothotlong
andcommitted
feat: add translation completeness tooling (typegen, skeleton, validator)
- Add StrictObjectTranslation type utility for compile-time field/option enforcement - Add generateTranslationSkeleton() for AI-friendly translation templates - Add validateTranslationCompleteness() for runtime completeness validation - Make ObjectSchema.create() generic to preserve field key types - Update zh-CN/en/ja-JP translations with missing category and recurrence_type options - Add satisfies pattern to zh-CN.ts as reference example - Add comprehensive unit tests (27 new tests in spec, 52 in example) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 3e09529 commit 88de57a

12 files changed

Lines changed: 852 additions & 9 deletions

examples/app-todo/src/translations/en.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,17 @@ export const en: TranslationData = {
3535
urgent: 'Urgent',
3636
},
3737
},
38-
category: { label: 'Category' },
38+
category: {
39+
label: 'Category',
40+
options: {
41+
personal: 'Personal',
42+
work: 'Work',
43+
shopping: 'Shopping',
44+
health: 'Health',
45+
finance: 'Finance',
46+
other: 'Other',
47+
},
48+
},
3949
due_date: { label: 'Due Date' },
4050
reminder_date: { label: 'Reminder Date/Time' },
4151
completed_date: { label: 'Completed Date' },
@@ -51,7 +61,15 @@ export const en: TranslationData = {
5161
},
5262
},
5363
is_recurring: { label: 'Recurring Task' },
54-
recurrence_type: { label: 'Recurrence Type' },
64+
recurrence_type: {
65+
label: 'Recurrence Type',
66+
options: {
67+
daily: 'Daily',
68+
weekly: 'Weekly',
69+
monthly: 'Monthly',
70+
yearly: 'Yearly',
71+
},
72+
},
5573
recurrence_interval: { label: 'Recurrence Interval' },
5674
is_completed: { label: 'Is Completed' },
5775
is_overdue: { label: 'Is Overdue' },

examples/app-todo/src/translations/ja-JP.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,17 @@ export const jaJP: TranslationData = {
3434
urgent: '緊急',
3535
},
3636
},
37-
category: { label: 'カテゴリ' },
37+
category: {
38+
label: 'カテゴリ',
39+
options: {
40+
personal: '個人',
41+
work: '仕事',
42+
shopping: '買い物',
43+
health: '健康',
44+
finance: '財務',
45+
other: 'その他',
46+
},
47+
},
3848
due_date: { label: '期日' },
3949
reminder_date: { label: 'リマインダー日時' },
4050
completed_date: { label: '完了日' },
@@ -50,7 +60,15 @@ export const jaJP: TranslationData = {
5060
},
5161
},
5262
is_recurring: { label: '繰り返しタスク' },
53-
recurrence_type: { label: '繰り返しタイプ' },
63+
recurrence_type: {
64+
label: '繰り返しタイプ',
65+
options: {
66+
daily: '毎日',
67+
weekly: '毎週',
68+
monthly: '毎月',
69+
yearly: '毎年',
70+
},
71+
},
5472
recurrence_interval: { label: '繰り返し間隔' },
5573
is_completed: { label: '完了済み' },
5674
is_overdue: { label: '期限超過' },
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { Task } from '../objects/task.object';
5+
import { en } from './en';
6+
import { zhCN } from './zh-CN';
7+
import type { TranslationData } from '@objectstack/spec/system';
8+
9+
/**
10+
* Translation Completeness Test
11+
*
12+
* Validates that every field and every select option in the Task object
13+
* definition has a corresponding translation in each locale.
14+
*/
15+
16+
const fieldNames = Object.keys(Task.fields);
17+
18+
const selectFields = Object.entries(Task.fields)
19+
.filter(([, f]) => Array.isArray(f.options) && f.options.length > 0)
20+
.map(([name, f]) => ({
21+
name,
22+
values: f.options!.map((o: { value: string }) => o.value),
23+
}));
24+
25+
describe.each([
26+
['en', en],
27+
['zh-CN', zhCN],
28+
] as [string, TranslationData][])('%s translation completeness', (locale, t) => {
29+
30+
it('should have task object translation', () => {
31+
expect(t.objects?.task).toBeDefined();
32+
expect(t.objects?.task?.label).toBeTruthy();
33+
});
34+
35+
it.each(fieldNames)('field: %s', (name) => {
36+
expect(
37+
t.objects?.task?.fields?.[name]?.label,
38+
`[${locale}] Missing label for field "${name}"`,
39+
).toBeTruthy();
40+
});
41+
42+
it.each(selectFields)('options: $name', ({ name, values }) => {
43+
for (const v of values) {
44+
expect(
45+
t.objects?.task?.fields?.[name]?.options?.[v],
46+
`[${locale}] Missing option "${v}" for field "${name}"`,
47+
).toBeTruthy();
48+
}
49+
});
50+
});

examples/app-todo/src/translations/zh-CN.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { TranslationData } from '@objectstack/spec/system';
4+
import type { StrictObjectTranslation } from '@objectstack/spec/system';
5+
import { Task } from '../objects/task.object';
6+
7+
type TaskTranslation = StrictObjectTranslation<typeof Task>;
48

59
/**
610
* 简体中文 (zh-CN) — Todo App Translations
@@ -34,7 +38,17 @@ export const zhCN: TranslationData = {
3438
urgent: '紧急',
3539
},
3640
},
37-
category: { label: '分类' },
41+
category: {
42+
label: '分类',
43+
options: {
44+
personal: '个人',
45+
work: '工作',
46+
shopping: '购物',
47+
health: '健康',
48+
finance: '财务',
49+
other: '其他',
50+
},
51+
},
3852
due_date: { label: '截止日期' },
3953
reminder_date: { label: '提醒日期/时间' },
4054
completed_date: { label: '完成日期' },
@@ -50,7 +64,15 @@ export const zhCN: TranslationData = {
5064
},
5165
},
5266
is_recurring: { label: '周期性任务' },
53-
recurrence_type: { label: '重复类型' },
67+
recurrence_type: {
68+
label: '重复类型',
69+
options: {
70+
daily: '每天',
71+
weekly: '每周',
72+
monthly: '每月',
73+
yearly: '每年',
74+
},
75+
},
5476
recurrence_interval: { label: '重复间隔' },
5577
is_completed: { label: '是否完成' },
5678
is_overdue: { label: '是否逾期' },
@@ -60,7 +82,7 @@ export const zhCN: TranslationData = {
6082
notes: { label: '备注' },
6183
category_color: { label: '分类颜色' },
6284
},
63-
},
85+
} satisfies TaskTranslation,
6486
},
6587
apps: {
6688
todo_app: {

packages/spec/src/data/object.zod.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,12 +378,12 @@ export const ObjectSchema = Object.assign(ObjectSchemaBase, {
378378
* });
379379
* ```
380380
*/
381-
create: (config: z.input<typeof ObjectSchemaBase>): ServiceObject => {
381+
create: <const T extends z.input<typeof ObjectSchemaBase>>(config: T): ServiceObject & Pick<T, 'fields'> => {
382382
const withDefaults = {
383383
...config,
384384
label: config.label ?? snakeCaseToLabel(config.name),
385385
};
386-
return ObjectSchemaBase.parse(withDefaults);
386+
return ObjectSchemaBase.parse(withDefaults) as ServiceObject & Pick<T, 'fields'>;
387387
},
388388
});
389389

packages/spec/src/system/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export * from './job.zod';
4141
export * from './worker.zod';
4242
export * from './notification.zod';
4343
export * from './translation.zod';
44+
export * from './translation-typegen';
45+
export * from './translation-skeleton';
46+
export * from './translation-validator';
4447
export * from './collaboration.zod';
4548
export * from './metadata-persistence.zod';
4649
export * from './core-services.zod';
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { generateTranslationSkeleton, TRANSLATE_PLACEHOLDER } from './translation-skeleton';
3+
import type { ServiceObject } from '../data/object.zod';
4+
5+
// ────────────────────────────────────────────────────────────────────────────
6+
// Test fixture — minimal ServiceObject
7+
// ────────────────────────────────────────────────────────────────────────────
8+
9+
const mockObject = {
10+
name: 'test_obj',
11+
label: 'Test Object',
12+
pluralLabel: 'Test Objects',
13+
fields: {
14+
title: { type: 'text', label: 'Title' },
15+
body: { type: 'textarea', label: 'Body', description: 'Main content area' },
16+
status: {
17+
type: 'select',
18+
label: 'Status',
19+
options: [
20+
{ label: 'Open', value: 'open' },
21+
{ label: 'Closed', value: 'closed' },
22+
],
23+
},
24+
priority: {
25+
type: 'select',
26+
label: 'Priority',
27+
options: [
28+
{ label: 'Low', value: 'low' },
29+
{ label: 'Normal', value: 'normal' },
30+
{ label: 'High', value: 'high' },
31+
],
32+
},
33+
plain: { type: 'number', label: 'Amount' },
34+
},
35+
} as unknown as ServiceObject;
36+
37+
describe('generateTranslationSkeleton', () => {
38+
39+
it('should output valid JSON', () => {
40+
const json = generateTranslationSkeleton(mockObject);
41+
expect(() => JSON.parse(json)).not.toThrow();
42+
});
43+
44+
it('should include object label and pluralLabel', () => {
45+
const skeleton = JSON.parse(generateTranslationSkeleton(mockObject));
46+
expect(skeleton.label).toContain(TRANSLATE_PLACEHOLDER);
47+
expect(skeleton.label).toContain('Test Object');
48+
expect(skeleton.pluralLabel).toContain('Test Objects');
49+
});
50+
51+
it('should include all field keys', () => {
52+
const skeleton = JSON.parse(generateTranslationSkeleton(mockObject));
53+
const fieldKeys = Object.keys(skeleton.fields);
54+
expect(fieldKeys).toEqual(['title', 'body', 'status', 'priority', 'plain']);
55+
});
56+
57+
it('should add help placeholder for fields with description', () => {
58+
const skeleton = JSON.parse(generateTranslationSkeleton(mockObject));
59+
expect(skeleton.fields.body.help).toContain(TRANSLATE_PLACEHOLDER);
60+
expect(skeleton.fields.body.help).toContain('Main content area');
61+
// Field without description should not have help
62+
expect(skeleton.fields.title.help).toBeUndefined();
63+
});
64+
65+
it('should include options map for select fields', () => {
66+
const skeleton = JSON.parse(generateTranslationSkeleton(mockObject));
67+
expect(skeleton.fields.status.options).toEqual({
68+
open: `${TRANSLATE_PLACEHOLDER}: "Open"`,
69+
closed: `${TRANSLATE_PLACEHOLDER}: "Closed"`,
70+
});
71+
expect(skeleton.fields.priority.options).toEqual({
72+
low: `${TRANSLATE_PLACEHOLDER}: "Low"`,
73+
normal: `${TRANSLATE_PLACEHOLDER}: "Normal"`,
74+
high: `${TRANSLATE_PLACEHOLDER}: "High"`,
75+
});
76+
});
77+
78+
it('should not include options for non-select fields', () => {
79+
const skeleton = JSON.parse(generateTranslationSkeleton(mockObject));
80+
expect(skeleton.fields.title.options).toBeUndefined();
81+
expect(skeleton.fields.plain.options).toBeUndefined();
82+
});
83+
84+
it('should handle object without pluralLabel', () => {
85+
const objWithoutPlural = {
86+
...mockObject,
87+
pluralLabel: undefined,
88+
} as unknown as ServiceObject;
89+
const skeleton = JSON.parse(generateTranslationSkeleton(objWithoutPlural));
90+
expect(skeleton.pluralLabel).toBeUndefined();
91+
});
92+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { ServiceObject } from '../data/object.zod';
4+
5+
/**
6+
* Translation Skeleton Generator
7+
*
8+
* Generates an AI-friendly JSON "fill-in-the-blank" template from an object
9+
* definition. The output contains `__TRANSLATE__` placeholders for every
10+
* translatable string, ensuring AI/human translators cannot accidentally
11+
* add or omit fields.
12+
*
13+
* @example
14+
* ```typescript
15+
* import { generateTranslationSkeleton } from '@objectstack/spec/system';
16+
* import { Task } from './objects/task.object';
17+
*
18+
* const skeleton = generateTranslationSkeleton(Task);
19+
* // → JSON string with __TRANSLATE__ placeholders for all 18 fields
20+
* ```
21+
*/
22+
23+
/** Placeholder prefix used in skeleton output */
24+
export const TRANSLATE_PLACEHOLDER = '__TRANSLATE__';
25+
26+
/**
27+
* Generates a translation skeleton JSON string from an object definition.
28+
*
29+
* The skeleton includes:
30+
* - Object-level `label` and `pluralLabel`
31+
* - Every field with a `label` placeholder
32+
* - `help` placeholder for fields that have a `description`
33+
* - `options` map for select/multiselect fields with all option values
34+
*
35+
* @param objectDef - A parsed ServiceObject definition
36+
* @returns A formatted JSON string with `__TRANSLATE__` placeholders
37+
*/
38+
export function generateTranslationSkeleton(objectDef: ServiceObject): string {
39+
const skeleton: Record<string, unknown> = {
40+
label: `${TRANSLATE_PLACEHOLDER}: "${objectDef.label}"`,
41+
};
42+
43+
if (objectDef.pluralLabel) {
44+
skeleton.pluralLabel = `${TRANSLATE_PLACEHOLDER}: "${objectDef.pluralLabel}"`;
45+
}
46+
47+
const fieldsObj: Record<string, Record<string, unknown>> = {};
48+
49+
for (const [fieldName, fieldDef] of Object.entries(objectDef.fields)) {
50+
const fieldEntry: Record<string, unknown> = {
51+
label: `${TRANSLATE_PLACEHOLDER}: "${fieldDef.label ?? fieldName}"`,
52+
};
53+
54+
// Add help placeholder for fields with a description
55+
if (fieldDef.description) {
56+
fieldEntry.help = `${TRANSLATE_PLACEHOLDER}: "${fieldDef.description}"`;
57+
}
58+
59+
// Add options map for select/multiselect fields
60+
if (fieldDef.options && fieldDef.options.length > 0) {
61+
const optionsMap: Record<string, string> = {};
62+
for (const opt of fieldDef.options) {
63+
optionsMap[opt.value] = `${TRANSLATE_PLACEHOLDER}: "${opt.label}"`;
64+
}
65+
fieldEntry.options = optionsMap;
66+
}
67+
68+
fieldsObj[fieldName] = fieldEntry;
69+
}
70+
71+
skeleton.fields = fieldsObj;
72+
73+
return JSON.stringify(skeleton, null, 2);
74+
}

0 commit comments

Comments
 (0)