Skip to content

Commit 69be3c0

Browse files
authored
Merge pull request #914 from objectstack-ai/copilot/fix-i18n-registration-issues
2 parents faf1fed + 8e31dd9 commit 69be3c0

13 files changed

Lines changed: 308 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Locale code fallback** — New `resolveLocale()` helper in `@objectstack/core` that resolves
12+
locale codes through a 4-step fallback chain: exact match → case-insensitive match
13+
(`zh-cn``zh-CN`) → base language match (`zh-CN``zh`) → variant expansion
14+
(`zh``zh-CN`). Used by `createMemoryI18n`, `HttpDispatcher.handleI18n()`, and
15+
`I18nServicePlugin` route handlers.
16+
- **Auto-detection of I18nServicePlugin** — Both `DevPlugin` and CLI `serve` command now
17+
automatically detect `translations`/`i18n` config in the stack definition and register
18+
`I18nServicePlugin` from `@objectstack/service-i18n` when available. Falls back to
19+
the core in-memory i18n (with locale resolution) if the package is not installed.
20+
- **Enhanced i18n diagnostics**`AppPlugin` now emits clear warnings when:
21+
- Translations exist but no i18n service is registered (guides user to add the plugin).
22+
- Translations are loaded into a fallback/stub i18n service (recommends production plugin).
23+
- **i18n locale fallback in REST routes**`HttpDispatcher.handleI18n()` translations and labels
24+
endpoints now resolve locale codes via fallback when exact match returns empty translations.
25+
The response includes `requestedLocale` when a fallback was used.
26+
27+
### Changed
28+
- **DevPlugin i18n stub** — Replaced the duplicate `createI18nStub()` in `DevPlugin` with
29+
`createMemoryI18n` from `@objectstack/core`, ensuring locale fallback works consistently
30+
in dev mode. DevPlugin now tries `I18nServicePlugin` before the stub when stack has translations.
31+
- `createMemoryI18n` now uses `resolveLocale()` internally for `t()` and `getTranslations()`,
32+
enabling locale fallback (e.g. `zh``zh-CN`) without any plugin changes.
33+
- CLI `serve` command now auto-registers `I18nServicePlugin` when config has translations/i18n,
34+
mirroring DevPlugin's auto-detection behavior for production environments.
35+
1036
### Changed
1137
- **i18n route self-registration** — Moved i18n REST endpoint registration from `RestServer` to
1238
`I18nServicePlugin` (and kernel fallback). The i18n plugin now self-registers `/api/v1/i18n/*`

packages/cli/src/commands/serve.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,12 +196,44 @@ export default class Serve extends Command {
196196
// and startup failures (e.g. plugin.app.dev-workspace).
197197
if (!isHostConfig(config) && (config.objects || config.manifest || config.apps)) {
198198
try {
199-
const { AppPlugin } = await import('@objectstack/runtime');
200-
await kernel.use(new AppPlugin(config));
201-
trackPlugin('App');
199+
const { AppPlugin } = await import('@objectstack/runtime');
200+
await kernel.use(new AppPlugin(config));
201+
trackPlugin('App');
202202
} catch (e: any) {
203-
// silent
203+
// silent
204+
}
205+
}
206+
207+
// 3b. Auto-register I18nServicePlugin if config contains translations/i18n
208+
// This ensures i18n REST routes work out of the box without manual plugin registration.
209+
const hasI18nPlugin = plugins.some(
210+
(p: any) => p.name === 'com.objectstack.service.i18n'
211+
|| p.constructor?.name === 'I18nServicePlugin'
212+
);
213+
const configHasTranslations = (
214+
(Array.isArray(config.translations) && config.translations.length > 0)
215+
|| config.i18n
216+
|| (config.manifest && (
217+
(Array.isArray(config.manifest.translations) && config.manifest.translations.length > 0)
218+
|| config.manifest.i18n
219+
))
220+
);
221+
if (!hasI18nPlugin && configHasTranslations) {
222+
try {
223+
// Dynamic import with variable to prevent tsc from resolving the optional package
224+
const i18nPkg = '@objectstack/service-i18n';
225+
const { I18nServicePlugin } = await import(/* webpackIgnore: true */ i18nPkg);
226+
const i18nCfg = config.i18n || config.manifest?.i18n || {};
227+
await kernel.use(new I18nServicePlugin({
228+
defaultLocale: i18nCfg.defaultLocale,
229+
fallbackLocale: i18nCfg.fallbackLocale || i18nCfg.defaultLocale || 'en',
230+
}));
231+
trackPlugin('I18nService');
232+
} catch {
233+
// @objectstack/service-i18n not installed — kernel memory fallback will handle i18n
204234
}
235+
} else if (!hasI18nPlugin && !configHasTranslations) {
236+
// No translations and no explicit i18n plugin — this is fine, kernel fallback works
205237
}
206238

207239
// Add HTTP server plugin BEFORE config plugins so that the

packages/core/src/fallbacks/fallbacks.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
22
import { createMemoryCache } from './memory-cache';
33
import { createMemoryQueue } from './memory-queue';
44
import { createMemoryJob } from './memory-job';
5-
import { createMemoryI18n } from './memory-i18n';
5+
import { createMemoryI18n, resolveLocale } from './memory-i18n';
66
import { CORE_FALLBACK_FACTORIES } from './index';
77

88
describe('CORE_FALLBACK_FACTORIES', () => {
@@ -222,3 +222,60 @@ describe('createMemoryI18n', () => {
222222
expect(i18n.getTranslations('en')).toEqual({ hello: 'Hello', bye: 'Goodbye' });
223223
});
224224
});
225+
226+
describe('resolveLocale', () => {
227+
it('should return exact match', () => {
228+
expect(resolveLocale('zh-CN', ['en', 'zh-CN', 'ja'])).toBe('zh-CN');
229+
});
230+
231+
it('should return case-insensitive match', () => {
232+
expect(resolveLocale('zh-cn', ['en', 'zh-CN'])).toBe('zh-CN');
233+
expect(resolveLocale('EN-US', ['en-US', 'zh-CN'])).toBe('en-US');
234+
});
235+
236+
it('should return base language match', () => {
237+
expect(resolveLocale('zh-TW', ['en', 'zh'])).toBe('zh');
238+
});
239+
240+
it('should return variant expansion (zh → zh-CN)', () => {
241+
expect(resolveLocale('zh', ['en', 'zh-CN', 'zh-TW'])).toBe('zh-CN');
242+
});
243+
244+
it('should return undefined for no match', () => {
245+
expect(resolveLocale('fr', ['en', 'zh-CN'])).toBeUndefined();
246+
});
247+
248+
it('should return undefined for empty available locales', () => {
249+
expect(resolveLocale('en', [])).toBeUndefined();
250+
});
251+
252+
it('should handle es → es-ES expansion', () => {
253+
expect(resolveLocale('es', ['en', 'es-ES', 'fr'])).toBe('es-ES');
254+
});
255+
});
256+
257+
describe('createMemoryI18n locale fallback', () => {
258+
it('should resolve translations via locale fallback (zh → zh-CN)', () => {
259+
const i18n = createMemoryI18n();
260+
i18n.loadTranslations('zh-CN', { hello: '你好' });
261+
expect(i18n.getTranslations('zh')).toEqual({ hello: '你好' });
262+
});
263+
264+
it('should resolve translations via case-insensitive fallback (zh-cn → zh-CN)', () => {
265+
const i18n = createMemoryI18n();
266+
i18n.loadTranslations('zh-CN', { hello: '你好' });
267+
expect(i18n.getTranslations('zh-cn')).toEqual({ hello: '你好' });
268+
});
269+
270+
it('should translate via locale fallback (zh → zh-CN)', () => {
271+
const i18n = createMemoryI18n();
272+
i18n.loadTranslations('zh-CN', { greeting: '你好世界' });
273+
expect(i18n.t('greeting', 'zh')).toBe('你好世界');
274+
});
275+
276+
it('should still fall back to default locale when no locale match at all', () => {
277+
const i18n = createMemoryI18n();
278+
i18n.loadTranslations('en', { hello: 'Hello' });
279+
expect(i18n.t('hello', 'ja')).toBe('Hello');
280+
});
281+
});

packages/core/src/fallbacks/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { createMemoryI18n } from './memory-i18n.js';
88
export { createMemoryCache } from './memory-cache.js';
99
export { createMemoryQueue } from './memory-queue.js';
1010
export { createMemoryJob } from './memory-job.js';
11-
export { createMemoryI18n } from './memory-i18n.js';
11+
export { createMemoryI18n, resolveLocale } from './memory-i18n.js';
1212

1313
/**
1414
* Map of core-criticality service names to their in-memory fallback factories.

packages/core/src/fallbacks/memory-i18n.ts

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

3+
/**
4+
* Resolve a locale code against available locales with fallback.
5+
*
6+
* Fallback chain:
7+
* 1. Exact match (e.g. `zh-CN` → `zh-CN`)
8+
* 2. Case-insensitive match (e.g. `zh-cn` → `zh-CN`)
9+
* 3. Base language match (e.g. `zh-CN` → `zh`)
10+
* 4. Variant expansion (e.g. `zh` → `zh-CN`)
11+
*
12+
* Returns the matched locale code, or `undefined` when no match is found.
13+
*/
14+
export function resolveLocale(requestedLocale: string, availableLocales: string[]): string | undefined {
15+
if (availableLocales.length === 0) return undefined;
16+
17+
// 1. Exact match
18+
if (availableLocales.includes(requestedLocale)) return requestedLocale;
19+
20+
// 2. Case-insensitive match
21+
const lower = requestedLocale.toLowerCase();
22+
const caseMatch = availableLocales.find(l => l.toLowerCase() === lower);
23+
if (caseMatch) return caseMatch;
24+
25+
// 3. Base language match (zh-CN → zh)
26+
const baseLang = requestedLocale.split('-')[0].toLowerCase();
27+
const baseMatch = availableLocales.find(l => l.toLowerCase() === baseLang);
28+
if (baseMatch) return baseMatch;
29+
30+
// 4. Variant expansion (zh → zh-CN, zh-TW, etc. — first match wins)
31+
const variantMatch = availableLocales.find(l => l.split('-')[0].toLowerCase() === baseLang);
32+
if (variantMatch) return variantMatch;
33+
34+
return undefined;
35+
}
36+
337
/**
438
* In-memory i18n service fallback.
539
*
640
* Implements the II18nService contract with basic translate/load/getLocales
741
* operations. Used by ObjectKernel as an automatic fallback when no real
842
* i18n plugin (e.g. I18nServicePlugin) is registered.
943
*
10-
* Supports runtime translation loading and locale management.
44+
* Supports runtime translation loading, locale management, and
45+
* locale code fallback (e.g. `zh` → `zh-CN`).
1146
* Does not load files from disk — operates purely in-memory.
1247
*/
1348
export function createMemoryI18n() {
@@ -27,11 +62,25 @@ export function createMemoryI18n() {
2762
return typeof current === 'string' ? current : undefined;
2863
}
2964

65+
/**
66+
* Find translation data for a locale, with fallback resolution.
67+
*/
68+
function resolveTranslations(locale: string): Record<string, unknown> | undefined {
69+
// Exact match
70+
if (translations.has(locale)) return translations.get(locale);
71+
72+
// Locale fallback (zh → zh-CN, en-us → en-US, etc.)
73+
const resolved = resolveLocale(locale, [...translations.keys()]);
74+
if (resolved) return translations.get(resolved);
75+
76+
return undefined;
77+
}
78+
3079
return {
3180
_fallback: true, _serviceName: 'i18n',
3281

3382
t(key: string, locale: string, params?: Record<string, unknown>): string {
34-
const data = translations.get(locale) ?? translations.get(defaultLocale);
83+
const data = resolveTranslations(locale) ?? translations.get(defaultLocale);
3584
const value = data ? resolveKey(data, key) : undefined;
3685
if (value == null) return key;
3786
if (!params) return value;
@@ -40,7 +89,7 @@ export function createMemoryI18n() {
4089
},
4190

4291
getTranslations(locale: string): Record<string, unknown> {
43-
return translations.get(locale) ?? {};
92+
return resolveTranslations(locale) ?? {};
4493
},
4594

4695
loadTranslations(locale: string, data: Record<string, unknown>): void {

packages/plugins/plugin-dev/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"@objectstack/plugin-auth": "workspace:^",
2828
"@objectstack/plugin-hono-server": "workspace:^",
2929
"@objectstack/plugin-security": "workspace:^",
30-
"@objectstack/rest": "workspace:^"
30+
"@objectstack/rest": "workspace:^",
31+
"@objectstack/service-i18n": "workspace:^"
3132
},
3233
"peerDependenciesMeta": {
3334
"@objectstack/driver-memory": {
@@ -50,6 +51,9 @@
5051
},
5152
"@objectstack/rest": {
5253
"optional": true
54+
},
55+
"@objectstack/service-i18n": {
56+
"optional": true
5357
}
5458
},
5559
"devDependencies": {
@@ -60,6 +64,7 @@
6064
"@objectstack/plugin-hono-server": "workspace:*",
6165
"@objectstack/plugin-security": "workspace:*",
6266
"@objectstack/rest": "workspace:*",
67+
"@objectstack/service-i18n": "workspace:*",
6368
"@types/node": "^25.3.5",
6469
"typescript": "^5.0.0",
6570
"vitest": "^4.0.18"

packages/plugins/plugin-dev/src/dev-plugin.ts

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

3-
import { Plugin, PluginContext, createMemoryCache, createMemoryQueue, createMemoryJob } from '@objectstack/core';
3+
import { Plugin, PluginContext, createMemoryCache, createMemoryQueue, createMemoryJob, createMemoryI18n } from '@objectstack/core';
44

55
/**
66
* All 17 core kernel service names as defined in CoreServiceName.
@@ -150,25 +150,12 @@ function createAIStub() {
150150
};
151151
}
152152

153-
/** II18nService — in-memory translation stub */
153+
/** II18nService — delegates to createMemoryI18n from core with locale fallback */
154154
function createI18nStub() {
155-
const translations = new Map<string, Record<string, unknown>>();
156-
let defaultLocale = 'en';
155+
const base = createMemoryI18n();
157156
return {
157+
...base,
158158
_dev: true, _serviceName: 'i18n',
159-
t(key: string, locale: string, params?: Record<string, unknown>): string {
160-
const t = translations.get(locale);
161-
const val = t?.[key];
162-
if (typeof val === 'string') {
163-
return params ? val.replace(/\{\{(\w+)\}\}/g, (_, k) => String(params[k] ?? `{{${k}}}`)) : val;
164-
}
165-
return key;
166-
},
167-
getTranslations(locale: string): Record<string, unknown> { return translations.get(locale) ?? {}; },
168-
loadTranslations(locale: string, data: Record<string, unknown>) { translations.set(locale, { ...translations.get(locale), ...data }); },
169-
getLocales() { return [...translations.keys()]; },
170-
getDefaultLocale() { return defaultLocale; },
171-
setDefaultLocale(locale: string) { defaultLocale = locale; },
172159
};
173160
}
174161

@@ -514,6 +501,35 @@ export class DevPlugin implements Plugin {
514501
}
515502
}
516503

504+
// 3b. I18n Plugin — auto-detect translations in stack definition
505+
// When the stack contains i18n/translations config, try to use
506+
// I18nServicePlugin (from @objectstack/service-i18n) for full-featured
507+
// file-based i18n. Falls back to the core in-memory i18n fallback
508+
// (with locale resolution) if the package is not installed.
509+
if (enabled('i18n') && this.options.stack) {
510+
const stack = this.options.stack;
511+
const hasTranslations = Array.isArray(stack.translations) && stack.translations.length > 0;
512+
const hasI18nConfig = !!(stack.i18n || (stack.manifest && stack.manifest.i18n));
513+
const hasManifestTranslations = !!(stack.manifest && Array.isArray(stack.manifest.translations) && stack.manifest.translations.length > 0);
514+
515+
if (hasTranslations || hasI18nConfig || hasManifestTranslations) {
516+
try {
517+
const { I18nServicePlugin } = await import('@objectstack/service-i18n') as any;
518+
const i18nConfig = stack.i18n || (stack.manifest || stack)?.i18n || {};
519+
const i18nPlugin = new I18nServicePlugin({
520+
defaultLocale: i18nConfig.defaultLocale,
521+
fallbackLocale: i18nConfig.fallbackLocale || i18nConfig.defaultLocale || 'en',
522+
});
523+
this.childPlugins.push(i18nPlugin);
524+
ctx.logger.info(' ✔ I18nServicePlugin auto-registered (translations detected in stack)');
525+
} catch {
526+
ctx.logger.info(
527+
' ℹ @objectstack/service-i18n not installed — using core in-memory i18n fallback with locale resolution'
528+
);
529+
}
530+
}
531+
}
532+
517533
// 4. Auth Plugin
518534
if (enabled('auth')) {
519535
try {

packages/runtime/src/app-plugin.test.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,9 @@ describe('AppPlugin', () => {
182182
const plugin = new AppPlugin(bundle);
183183
await plugin.start!(mockContext);
184184

185-
// Should log debug but not throw
186-
expect(mockContext.logger.debug).toHaveBeenCalledWith(
187-
expect.stringContaining('No i18n service registered'),
188-
expect.any(Object)
185+
// Should log warning (translations exist but no i18n service) and not throw
186+
expect(mockContext.logger.warn).toHaveBeenCalledWith(
187+
expect.stringContaining('no i18n service is registered')
189188
);
190189
});
191190

@@ -202,10 +201,9 @@ describe('AppPlugin', () => {
202201
const plugin = new AppPlugin(bundle);
203202
await plugin.start!(mockContext);
204203

205-
// Should log debug but not throw
206-
expect(mockContext.logger.debug).toHaveBeenCalledWith(
207-
expect.stringContaining('No i18n service registered'),
208-
expect.any(Object)
204+
// Should log warning (translations exist but no i18n service) and not throw
205+
expect(mockContext.logger.warn).toHaveBeenCalledWith(
206+
expect.stringContaining('no i18n service is registered')
209207
);
210208
});
211209

0 commit comments

Comments
 (0)