Skip to content

Commit 2289fe0

Browse files
committed
fix(service-datasource): translate the driver catalog into every platform language
The catalog carried Simplified Chinese only, which opened a language tier on a platform whose locale table lists eleven languages, each complete. The translations are now keyed by the platform table's tags and cover all ten non-English languages, written once per language and laid out per driver so a shared field reads one way. The requested code is read through the table's own reader, shortened one subtag at a time, so Traditional Chinese codes reach zh-Hant and never fall to the Simplified text. The coverage test walks PLATFORM_LOCALE_TAGS, so a missing language goes red. Claude-Session: https://claude.ai/code/session_016WV4czJVaL8SDBxjng4RLk Co-authored-by: Claude <noreply@anthropic.com>
1 parent b475fae commit 2289fe0

5 files changed

Lines changed: 762 additions & 153 deletions

File tree

‎.changeset/30563-driver-catalog-requester-language.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ fix(service-datasource): the datasource driver catalog serves its words in the r
77
Clause-②: no
88
Metadata-protocol: none
99

10-
`GET /api/v1/datasources/drivers` now serves each driver's label and description, and each connection field's `title` and `description` (Filename, Auto-migrate schema, Host, Port, …), in the language the request names: the top `Accept-Language` tag, else `?locale=`, matched by the platform's locale ladder (`resolveLocaleAmong`), so `zh`, `zh-cn` and `zh-SG` reach `zh-CN`. Simplified Chinese is translated for every offered driver. English requests, languages without a translation, and any word a translation leaves out are served the English text, never a key. `DRIVER_CATALOG` stays the English catalog, and the new `driverCatalogFor(locale)` in the package source builds the localized one; ids, icons, types and defaults are unchanged in every language.
10+
`GET /api/v1/datasources/drivers` now serves each driver's label and description, and each connection field's `title` and `description` (Filename, Auto-migrate schema, Host, Port, …), in the language the request names: the top `Accept-Language` tag, else `?locale=`. The code is read through the platform locale table (`platformLocaleTagOf`, shortened one subtag at a time), so `zh-TW` and `zh-HK` get Traditional Chinese, `zh`, `zh-CN` and `zh-SG` Simplified, `pt` Brazilian Portuguese and `fr-CA` French. Every language on the table is translated for every offered driver: Simplified and Traditional Chinese, Spanish, Arabic, French, Brazilian Portuguese, Russian, German, Japanese and Korean. English requests, codes the table does not name, and product names (SQLite, PostgreSQL, MySQL / MariaDB, MongoDB) are served the English text, never a key. `DRIVER_CATALOG` stays the English catalog, and the new `driverCatalogFor(locale)` in the package source builds the localized one; ids, icons, types and defaults are unchanged in every language.

‎packages/services/service-datasource/src/__tests__/admin-routes.test.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,17 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
146146
const query = await read(undefined, '/api/v1/datasources/drivers?locale=zh-CN');
147147
expect(query('sqlite').configSchema.properties.filename.title).toBe('文件名');
148148

149-
// English and an untranslated language are served the English words.
150-
for (const lang of ['en-US', 'fr-FR']) {
149+
// A Traditional Chinese reader gets Traditional, never the Simplified text.
150+
const zhTW = await read({ headers: { 'accept-language': 'zh-TW,zh;q=0.9' } });
151+
expect(zhTW('sqlite').configSchema.properties.filename.title).toBe('檔案名稱');
152+
expect(zhTW('postgres').configSchema.properties.port.title).toBe('連接埠');
153+
154+
// Every other language on the platform table is served too.
155+
const fr = await read({ headers: { 'accept-language': 'fr-FR,fr;q=0.9' } });
156+
expect(fr('sqlite').configSchema.properties.filename.title).toBe('Nom de fichier');
157+
158+
// English, and a language the platform table does not name, are served the English words.
159+
for (const lang of ['en-US', 'it-IT']) {
151160
const en = await read({ headers: { 'accept-language': lang } });
152161
expect(en('sqlite').configSchema.properties.filename.title, lang).toBe('Filename');
153162
expect(en('postgres').configSchema.properties.host.title, lang).toBe('Host');

‎packages/services/service-datasource/src/__tests__/driver-catalog.test.ts‎

Lines changed: 81 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import {
2323
type BuiltinDriverId,
2424
} from '@objectstack/spec/data';
2525

26-
import { DRIVER_CATALOG, driverCatalogFor } from '../driver-catalog.js';
26+
import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system';
27+
28+
import { DRIVER_CATALOG, driverCatalogFor, driverCatalogLocaleOf } from '../driver-catalog.js';
2729
import { DRIVER_CATALOG_TRANSLATIONS } from '../driver-catalog-translations.js';
2830

2931
describe('DRIVER_CATALOG', () => {
@@ -92,6 +94,10 @@ const entryOf = (catalog: typeof DRIVER_CATALOG, id: string) => {
9294
if (!entry) throw new Error(`no ${id} entry`);
9395
return entry;
9496
};
97+
const filenameTitle = (tag: string | undefined) => propsOf(entryOf(driverCatalogFor(tag), 'sqlite')).filename.title;
98+
99+
/** The labels that are product names, served as written in every language. */
100+
const PRODUCT_NAME_LABELS = new Set(['sqlite', 'postgres', 'mysql', 'mongodb']);
95101

96102
/**
97103
* The catalog's words in the requester's language. The New datasource dialog
@@ -100,7 +106,7 @@ const entryOf = (catalog: typeof DRIVER_CATALOG, id: string) => {
100106
* `Host` and `Port` in English until the route served them translated.
101107
*/
102108
describe('driverCatalogFor', () => {
103-
it('serves zh-CN words for SQLite and the network drivers', () => {
109+
it('serves Simplified Chinese words for SQLite and the network drivers', () => {
104110
const zh = driverCatalogFor('zh-CN');
105111

106112
const sqlite = entryOf(zh, 'sqlite');
@@ -119,38 +125,70 @@ describe('driverCatalogFor', () => {
119125
expect(entryOf(zh, 'memory').label).toBe('内存');
120126
});
121127

122-
it('keeps everything but the words: ids, icons, types and defaults are the English entry\'s', () => {
123-
const zh = driverCatalogFor('zh-CN');
124-
expect(zh.map((d) => [d.id, d.icon])).toEqual(DRIVER_CATALOG.map((d) => [d.id, d.icon]));
125-
for (const entry of zh) {
126-
const english = propsOf(entryOf(DRIVER_CATALOG, entry.id));
127-
const translated = propsOf(entry);
128-
expect(Object.keys(translated), entry.id).toEqual(Object.keys(english));
129-
for (const key of Object.keys(english)) {
130-
const { title: _t, description: _d, ...rest } = translated[key];
131-
const { title: _et, description: _ed, ...englishRest } = english[key];
132-
expect(rest, `${entry.id}.${key}`).toEqual(englishRest);
133-
}
128+
it('serves a word of its own in every language on the platform table', () => {
129+
const expected: Record<string, string> = {
130+
'zh-Hans': '文件名',
131+
'zh-Hant': '檔案名稱',
132+
es: 'Nombre de archivo',
133+
ar: 'اسم الملف',
134+
fr: 'Nom de fichier',
135+
'pt-BR': 'Nome do arquivo',
136+
ru: 'Имя файла',
137+
de: 'Dateiname',
138+
ja: 'ファイル名',
139+
ko: '파일 이름',
140+
};
141+
expect(Object.keys(expected).sort()).toEqual(PLATFORM_LOCALE_TAGS.filter((t) => t !== 'en').sort());
142+
for (const [tag, title] of Object.entries(expected)) expect(filenameTitle(tag), tag).toBe(title);
143+
});
144+
145+
it('reads Traditional Chinese codes as Traditional, never Simplified', () => {
146+
for (const tag of ['zh-TW', 'zh-HK', 'zh-MO', 'zh-Hant', 'zh-hant', 'zh-Hant-TW', 'zh-Hant-HK']) {
147+
expect(driverCatalogLocaleOf(tag), tag).toBe('zh-Hant');
148+
expect(filenameTitle(tag), tag).toBe('檔案名稱');
149+
}
150+
for (const tag of ['zh', 'zh-CN', 'zh-cn', 'zh-SG', 'zh-Hans', 'zh-Hans-CN']) {
151+
expect(driverCatalogLocaleOf(tag), tag).toBe('zh-Hans');
152+
expect(filenameTitle(tag), tag).toBe('文件名');
134153
}
135154
});
136155

137-
it('negotiates the requested tag through the platform ladder', () => {
138-
for (const tag of ['zh', 'zh-cn', 'zh-SG', 'zh-Hans']) {
139-
expect(propsOf(entryOf(driverCatalogFor(tag), 'sqlite')).filename.title, tag).toBe('文件名');
156+
it('reads aliases and regional codes through the platform table', () => {
157+
const cases: Array<[string, string]> = [
158+
['ja-JP', 'ja'], ['es-ES', 'es'], ['es-MX', 'es'], ['pt', 'pt-BR'], ['pt-PT', 'pt-BR'],
159+
['fr-CA', 'fr'], ['de-AT', 'de'], ['ko-KR', 'ko'], ['ar-EG', 'ar'], ['ru-RU', 'ru'],
160+
];
161+
for (const [code, tag] of cases) expect(driverCatalogLocaleOf(code), code).toBe(tag);
162+
});
163+
164+
it('keeps everything but the words: ids, icons, types and defaults are the English entry\'s', () => {
165+
for (const tag of PLATFORM_LOCALE_TAGS) {
166+
const served = driverCatalogFor(tag);
167+
expect(served.map((d) => [d.id, d.icon]), tag).toEqual(DRIVER_CATALOG.map((d) => [d.id, d.icon]));
168+
for (const entry of served) {
169+
const english = propsOf(entryOf(DRIVER_CATALOG, entry.id));
170+
const translated = propsOf(entry);
171+
expect(Object.keys(translated), `${tag}.${entry.id}`).toEqual(Object.keys(english));
172+
for (const key of Object.keys(english)) {
173+
const { title: _t, description: _d, ...rest } = translated[key];
174+
const { title: _et, description: _ed, ...englishRest } = english[key];
175+
expect(rest, `${tag}.${entry.id}.${key}`).toEqual(englishRest);
176+
}
177+
}
140178
}
141179
});
142180

143-
it('serves English for no locale, en, or a locale nothing translates', () => {
144-
for (const tag of [undefined, '', 'en', 'en-US', 'fr', 'constructor', '__proto__']) {
181+
it('serves English for no locale, English, or a code the platform table does not name', () => {
182+
for (const tag of [undefined, '', 'en', 'en-US', 'en-GB', 'it', 'nl-NL', 'constructor', '__proto__']) {
145183
expect(driverCatalogFor(tag), String(tag)).toBe(DRIVER_CATALOG);
146184
}
147185
expect(propsOf(entryOf(DRIVER_CATALOG, 'sqlite')).filename.title).toBe('Filename');
148186
expect(propsOf(entryOf(DRIVER_CATALOG, 'postgres')).host.title).toBe('Host');
149187
});
150188

151-
it('falls back to the English word where a locale has none, never to a key', () => {
189+
it('falls back to the English word where a language has none, never to a key', () => {
152190
const zh = driverCatalogFor('zh-CN');
153-
// Product names are not in the zh-CN table: the English label is served.
191+
// Product names are not translated: the English label is served.
154192
expect(entryOf(zh, 'sqlite').label).toBe('SQLite');
155193
expect(entryOf(zh, 'postgres').label).toBe('PostgreSQL');
156194
// The memory driver's fields carry a description and no title in English;
@@ -160,7 +198,7 @@ describe('driverCatalogFor', () => {
160198
});
161199

162200
it('never writes the spec projection it overlays', () => {
163-
driverCatalogFor('zh-CN');
201+
for (const tag of PLATFORM_LOCALE_TAGS) driverCatalogFor(tag);
164202
const sqlite = getDriverConfigJsonSchemaById('sqlite') as { properties: Record<string, JsonProp> };
165203
expect(sqlite.properties.filename.title).toBe('Filename');
166204
expect(propsOf(entryOf(DRIVER_CATALOG, 'sqlite')).filename.title).toBe('Filename');
@@ -169,18 +207,28 @@ describe('driverCatalogFor', () => {
169207
});
170208

171209
/**
172-
* Every locale in `driver-catalog-translations.ts` against the served shape:
173-
* a driver or field it names must exist, so a renamed field cannot leave a
174-
* translation that silently stops applying, and every field word the English
175-
* form shows must be translated, so a field the spec adds goes red here rather
176-
* than reading English in a translated dialog.
210+
* Every language on the platform locale table against the served shape. It
211+
* walks `PLATFORM_LOCALE_TAGS`, not the translation table's own keys, so a
212+
* language the table lacks goes red here: the platform has one list of
213+
* languages, each complete. A driver or field a language names must exist, so
214+
* a renamed field cannot leave a translation that silently stops applying; and
215+
* every word the English form shows must be translated, so a field the spec
216+
* adds goes red here rather than reading English in a translated dialog.
177217
*/
178218
describe('DRIVER_CATALOG_TRANSLATIONS coverage', () => {
179219
const offered = new Set(DRIVER_CATALOG.map((d) => d.id));
220+
const languages = PLATFORM_LOCALE_TAGS.filter((tag) => tag !== 'en');
221+
222+
it('carries every non-English language on the platform table, and nothing else', () => {
223+
expect(PLATFORM_LOCALE_TAGS).toContain('en');
224+
expect(Object.keys(DRIVER_CATALOG_TRANSLATIONS).sort()).toEqual([...languages].sort());
225+
});
180226

181-
for (const [locale, table] of Object.entries(DRIVER_CATALOG_TRANSLATIONS)) {
227+
for (const locale of languages) {
182228
it(`${locale}: names only drivers and fields the catalog serves`, () => {
183-
for (const [id, text] of Object.entries(table)) {
229+
const table = DRIVER_CATALOG_TRANSLATIONS[locale];
230+
expect(table, locale).toBeDefined();
231+
for (const [id, text] of Object.entries(table ?? {})) {
184232
expect(offered.has(id), `${locale}.${id}`).toBe(true);
185233
const props = propsOf(entryOf(DRIVER_CATALOG, id));
186234
for (const key of Object.keys(text?.fields ?? {})) {
@@ -189,10 +237,12 @@ describe('DRIVER_CATALOG_TRANSLATIONS coverage', () => {
189237
}
190238
});
191239

192-
it(`${locale}: translates every driver description and every field word the English form shows`, () => {
240+
it(`${locale}: translates every word the English form shows`, () => {
241+
const table = DRIVER_CATALOG_TRANSLATIONS[locale];
193242
for (const entry of DRIVER_CATALOG) {
194-
const text = table[entry.id as keyof typeof table];
243+
const text = table?.[entry.id as keyof typeof table];
195244
expect(text?.description, `${locale}.${entry.id}.description`).toBeTruthy();
245+
if (!PRODUCT_NAME_LABELS.has(entry.id)) expect(text?.label, `${locale}.${entry.id}.label`).toBeTruthy();
196246
for (const [key, prop] of Object.entries(propsOf(entry))) {
197247
const field = text?.fields?.[key];
198248
if (prop.title !== undefined) expect(field?.title, `${locale}.${entry.id}.${key}.title`).toBeTruthy();

0 commit comments

Comments
 (0)