From 6f338928412c627f5b87d4bb38159c7f001cb0cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:05:38 +0000 Subject: [PATCH 1/4] Initial plan From f5d86ad2d9cab5cbf43242c8c3b54667bac84b37 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:17:07 +0000 Subject: [PATCH 2/4] feat(i18n): add locale fallback, auto-detect translations, and enhanced warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add resolveLocale() helper to core fallbacks for locale code fallback (zh → zh-CN, en-us → en-US, etc.) - Enhance createMemoryI18n() to use locale fallback in getTranslations() and t() - DevPlugin: replace duplicate createI18nStub with createMemoryI18n from core; auto-detect stack translations and try I18nServicePlugin before stub - AppPlugin: emit clear warnings when translations exist but no i18n service or only a fallback/stub is registered - HttpDispatcher: add locale fallback in handleI18n() for translations and labels - CLI serve: auto-detect translations in stack config and register I18nServicePlugin Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/cli/src/commands/serve.ts | 38 +++++++++++-- packages/core/src/fallbacks/index.ts | 2 +- packages/core/src/fallbacks/memory-i18n.ts | 55 ++++++++++++++++++- packages/plugins/plugin-dev/src/dev-plugin.ts | 50 +++++++++++------ packages/runtime/src/app-plugin.ts | 42 ++++++++++---- packages/runtime/src/http-dispatcher.ts | 27 ++++++++- 6 files changed, 174 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index f3e866060e..00f8f271b3 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -196,12 +196,42 @@ export default class Serve extends Command { // and startup failures (e.g. plugin.app.dev-workspace). if (!isHostConfig(config) && (config.objects || config.manifest || config.apps)) { try { - const { AppPlugin } = await import('@objectstack/runtime'); - await kernel.use(new AppPlugin(config)); - trackPlugin('App'); + const { AppPlugin } = await import('@objectstack/runtime'); + await kernel.use(new AppPlugin(config)); + trackPlugin('App'); } catch (e: any) { - // silent + // silent + } + } + + // 3b. Auto-register I18nServicePlugin if config contains translations/i18n + // This ensures i18n REST routes work out of the box without manual plugin registration. + const hasI18nPlugin = plugins.some( + (p: any) => p.name === 'com.objectstack.service.i18n' + || p.constructor?.name === 'I18nServicePlugin' + ); + const configHasTranslations = ( + (Array.isArray(config.translations) && config.translations.length > 0) + || config.i18n + || (config.manifest && ( + (Array.isArray(config.manifest.translations) && config.manifest.translations.length > 0) + || config.manifest.i18n + )) + ); + if (!hasI18nPlugin && configHasTranslations) { + try { + const { I18nServicePlugin } = await import('@objectstack/service-i18n'); + const i18nCfg = config.i18n || config.manifest?.i18n || {}; + await kernel.use(new I18nServicePlugin({ + defaultLocale: i18nCfg.defaultLocale, + fallbackLocale: i18nCfg.fallbackLocale || i18nCfg.defaultLocale || 'en', + })); + trackPlugin('I18nService'); + } catch { + // @objectstack/service-i18n not installed — kernel memory fallback will handle i18n } + } else if (!hasI18nPlugin && !configHasTranslations) { + // No translations and no explicit i18n plugin — this is fine, kernel fallback works } // Add HTTP server plugin BEFORE config plugins so that the diff --git a/packages/core/src/fallbacks/index.ts b/packages/core/src/fallbacks/index.ts index e7c55b45ae..52eaa74075 100644 --- a/packages/core/src/fallbacks/index.ts +++ b/packages/core/src/fallbacks/index.ts @@ -8,7 +8,7 @@ import { createMemoryI18n } from './memory-i18n.js'; export { createMemoryCache } from './memory-cache.js'; export { createMemoryQueue } from './memory-queue.js'; export { createMemoryJob } from './memory-job.js'; -export { createMemoryI18n } from './memory-i18n.js'; +export { createMemoryI18n, resolveLocale } from './memory-i18n.js'; /** * Map of core-criticality service names to their in-memory fallback factories. diff --git a/packages/core/src/fallbacks/memory-i18n.ts b/packages/core/src/fallbacks/memory-i18n.ts index c52a3316b6..77909b0b98 100644 --- a/packages/core/src/fallbacks/memory-i18n.ts +++ b/packages/core/src/fallbacks/memory-i18n.ts @@ -1,5 +1,39 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +/** + * Resolve a locale code against available locales with fallback. + * + * Fallback chain: + * 1. Exact match (e.g. `zh-CN` → `zh-CN`) + * 2. Case-insensitive match (e.g. `zh-cn` → `zh-CN`) + * 3. Base language match (e.g. `zh-CN` → `zh`) + * 4. Variant expansion (e.g. `zh` → `zh-CN`) + * + * Returns the matched locale code, or `undefined` when no match is found. + */ +export function resolveLocale(requestedLocale: string, availableLocales: string[]): string | undefined { + if (availableLocales.length === 0) return undefined; + + // 1. Exact match + if (availableLocales.includes(requestedLocale)) return requestedLocale; + + // 2. Case-insensitive match + const lower = requestedLocale.toLowerCase(); + const caseMatch = availableLocales.find(l => l.toLowerCase() === lower); + if (caseMatch) return caseMatch; + + // 3. Base language match (zh-CN → zh) + const baseLang = requestedLocale.split('-')[0].toLowerCase(); + const baseMatch = availableLocales.find(l => l.toLowerCase() === baseLang); + if (baseMatch) return baseMatch; + + // 4. Variant expansion (zh → zh-CN, zh-TW, etc. — first match wins) + const variantMatch = availableLocales.find(l => l.split('-')[0].toLowerCase() === baseLang); + if (variantMatch) return variantMatch; + + return undefined; +} + /** * In-memory i18n service fallback. * @@ -7,7 +41,8 @@ * operations. Used by ObjectKernel as an automatic fallback when no real * i18n plugin (e.g. I18nServicePlugin) is registered. * - * Supports runtime translation loading and locale management. + * Supports runtime translation loading, locale management, and + * locale code fallback (e.g. `zh` → `zh-CN`). * Does not load files from disk — operates purely in-memory. */ export function createMemoryI18n() { @@ -27,11 +62,25 @@ export function createMemoryI18n() { return typeof current === 'string' ? current : undefined; } + /** + * Find translation data for a locale, with fallback resolution. + */ + function resolveTranslations(locale: string): Record | undefined { + // Exact match + if (translations.has(locale)) return translations.get(locale); + + // Locale fallback (zh → zh-CN, en-us → en-US, etc.) + const resolved = resolveLocale(locale, [...translations.keys()]); + if (resolved) return translations.get(resolved); + + return undefined; + } + return { _fallback: true, _serviceName: 'i18n', t(key: string, locale: string, params?: Record): string { - const data = translations.get(locale) ?? translations.get(defaultLocale); + const data = resolveTranslations(locale) ?? translations.get(defaultLocale); const value = data ? resolveKey(data, key) : undefined; if (value == null) return key; if (!params) return value; @@ -40,7 +89,7 @@ export function createMemoryI18n() { }, getTranslations(locale: string): Record { - return translations.get(locale) ?? {}; + return resolveTranslations(locale) ?? {}; }, loadTranslations(locale: string, data: Record): void { diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 1161a684f9..e4d185dfd7 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext, createMemoryCache, createMemoryQueue, createMemoryJob } from '@objectstack/core'; +import { Plugin, PluginContext, createMemoryCache, createMemoryQueue, createMemoryJob, createMemoryI18n } from '@objectstack/core'; /** * All 17 core kernel service names as defined in CoreServiceName. @@ -150,25 +150,12 @@ function createAIStub() { }; } -/** II18nService — in-memory translation stub */ +/** II18nService — delegates to createMemoryI18n from core with locale fallback */ function createI18nStub() { - const translations = new Map>(); - let defaultLocale = 'en'; + const base = createMemoryI18n(); return { + ...base, _dev: true, _serviceName: 'i18n', - t(key: string, locale: string, params?: Record): string { - const t = translations.get(locale); - const val = t?.[key]; - if (typeof val === 'string') { - return params ? val.replace(/\{\{(\w+)\}\}/g, (_, k) => String(params[k] ?? `{{${k}}}`)) : val; - } - return key; - }, - getTranslations(locale: string): Record { return translations.get(locale) ?? {}; }, - loadTranslations(locale: string, data: Record) { translations.set(locale, { ...translations.get(locale), ...data }); }, - getLocales() { return [...translations.keys()]; }, - getDefaultLocale() { return defaultLocale; }, - setDefaultLocale(locale: string) { defaultLocale = locale; }, }; } @@ -514,6 +501,35 @@ export class DevPlugin implements Plugin { } } + // 3b. I18n Plugin — auto-detect translations in stack definition + // When the stack contains i18n/translations config, try to use + // I18nServicePlugin (from @objectstack/service-i18n) for full-featured + // file-based i18n. Falls back to the core in-memory i18n fallback + // (with locale resolution) if the package is not installed. + if (enabled('i18n') && this.options.stack) { + const stack = this.options.stack; + const hasTranslations = Array.isArray(stack.translations) && stack.translations.length > 0; + const hasI18nConfig = !!(stack.i18n || (stack.manifest && stack.manifest.i18n)); + const hasManifestTranslations = !!(stack.manifest && Array.isArray(stack.manifest.translations) && stack.manifest.translations.length > 0); + + if (hasTranslations || hasI18nConfig || hasManifestTranslations) { + try { + const { I18nServicePlugin } = await import('@objectstack/service-i18n') as any; + const i18nConfig = stack.i18n || (stack.manifest || stack)?.i18n || {}; + const i18nPlugin = new I18nServicePlugin({ + defaultLocale: i18nConfig.defaultLocale, + fallbackLocale: i18nConfig.fallbackLocale || i18nConfig.defaultLocale || 'en', + }); + this.childPlugins.push(i18nPlugin); + ctx.logger.info(' ✔ I18nServicePlugin auto-registered (translations detected in stack)'); + } catch { + ctx.logger.info( + ' ℹ @objectstack/service-i18n not installed — using core in-memory i18n fallback with locale resolution' + ); + } + } + } + // 4. Auth Plugin if (enabled('auth')) { try { diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index fa839bea2e..20453ddd28 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -216,8 +216,27 @@ export class AppPlugin implements Plugin { // Service not registered — handled below } + // Collect translation bundles early to determine if we have data + const bundles: Array> = []; + if (Array.isArray(this.bundle.translations)) { + bundles.push(...this.bundle.translations); + } + const manifest = this.bundle.manifest || this.bundle; + if (manifest && Array.isArray(manifest.translations) && manifest.translations !== this.bundle.translations) { + bundles.push(...manifest.translations); + } + if (!i18nService) { - ctx.logger.debug('[i18n] No i18n service registered; skipping translation loading', { appId }); + if (bundles.length > 0) { + ctx.logger.warn( + `[i18n] App "${appId}" has ${bundles.length} translation bundle(s) but no i18n service is registered. ` + + 'Translations will not be served via REST API. ' + + 'Register I18nServicePlugin from @objectstack/service-i18n, or use DevPlugin ' + + 'which auto-detects translations and registers the i18n service automatically.' + ); + } else { + ctx.logger.debug('[i18n] No i18n service registered; skipping translation loading', { appId }); + } return; } @@ -228,16 +247,6 @@ export class AppPlugin implements Plugin { ctx.logger.debug('[i18n] Set default locale', { appId, locale: i18nConfig.defaultLocale }); } - // Collect translation bundles from top-level and legacy locations - const bundles: Array> = []; - if (Array.isArray(this.bundle.translations)) { - bundles.push(...this.bundle.translations); - } - const manifest = this.bundle.manifest || this.bundle; - if (manifest && Array.isArray(manifest.translations) && manifest.translations !== this.bundle.translations) { - bundles.push(...manifest.translations); - } - if (bundles.length === 0) { return; } @@ -257,6 +266,15 @@ export class AppPlugin implements Plugin { } } - ctx.logger.info('[i18n] Loaded translation bundles', { appId, bundles: bundles.length, locales: loadedLocales }); + // Emit diagnostic when the active i18n service is a fallback/stub + const svcAny = i18nService as Record; + if (svcAny._fallback || svcAny._dev) { + ctx.logger.info( + `[i18n] Loaded ${loadedLocales} locale(s) into in-memory i18n fallback for "${appId}". ` + + 'For production, consider registering I18nServicePlugin from @objectstack/service-i18n.' + ); + } else { + ctx.logger.info('[i18n] Loaded translation bundles', { appId, bundles: bundles.length, locales: loadedLocales }); + } } } diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 0f05a2a696..bde6063b28 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ObjectKernel, getEnv } from '@objectstack/core'; +import { ObjectKernel, getEnv, resolveLocale } from '@objectstack/core'; import { CoreServiceName } from '@objectstack/spec/system'; /** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */ @@ -620,15 +620,36 @@ export class HttpDispatcher { if (parts[0] === 'translations') { const locale = parts[1] ? decodeURIComponent(parts[1]) : query?.locale; if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) }; - const translations = i18nService.getTranslations(locale); + + let translations = i18nService.getTranslations(locale); + + // Locale fallback: try resolving to an available locale when + // the exact code yields empty translations (e.g. zh → zh-CN). + if (Object.keys(translations).length === 0) { + const availableLocales = typeof i18nService.getLocales === 'function' + ? i18nService.getLocales() : []; + const resolved = resolveLocale(locale, availableLocales); + if (resolved && resolved !== locale) { + translations = i18nService.getTranslations(resolved); + return { handled: true, response: this.success({ locale: resolved, requestedLocale: locale, translations }) }; + } + } + return { handled: true, response: this.success({ locale, translations }) }; } // GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx if (parts[0] === 'labels' && parts.length >= 2) { const objectName = decodeURIComponent(parts[1]); - const locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale; + let locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale; if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) }; + + // Locale fallback for labels endpoint + const availableLocales = typeof i18nService.getLocales === 'function' + ? i18nService.getLocales() : []; + const resolved = resolveLocale(locale, availableLocales); + if (resolved) locale = resolved; + if (typeof i18nService.getFieldLabels === 'function') { const labels = i18nService.getFieldLabels(objectName, locale); return { handled: true, response: this.success({ object: objectName, locale, labels }) }; From 32e46d034be62a50c6c7a198583f479e9136dbd9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:28:58 +0000 Subject: [PATCH 3/4] fix(i18n): fix service-i18n exports, add tests, update CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix service-i18n package.json exports (index.mjs → index.js for ESM) resolving "Cannot find module" error when importing @objectstack/service-i18n - Add @objectstack/service-i18n as optional peer dep in plugin-dev - Add locale fallback tests for resolveLocale() and createMemoryI18n - Add locale fallback tests for HttpDispatcher.handleI18n() - Update app-plugin tests for enhanced i18n warning behavior - Fix TypeScript cast in app-plugin (II18nService → unknown → Record) - Update CHANGELOG.md with i18n plugin registration mechanism changes Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- CHANGELOG.md | 26 ++++++++ packages/core/src/fallbacks/fallbacks.test.ts | 59 ++++++++++++++++++- packages/plugins/plugin-dev/package.json | 7 ++- packages/runtime/src/app-plugin.test.ts | 14 ++--- packages/runtime/src/app-plugin.ts | 2 +- packages/runtime/src/http-dispatcher.test.ts | 31 ++++++++++ packages/services/service-i18n/package.json | 4 +- pnpm-lock.yaml | 3 + 8 files changed, 133 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ac05d2031..68800fb19f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Locale code fallback** — New `resolveLocale()` helper in `@objectstack/core` that resolves + locale codes through a 4-step fallback chain: exact match → case-insensitive match + (`zh-cn` → `zh-CN`) → base language match (`zh-CN` → `zh`) → variant expansion + (`zh` → `zh-CN`). Used by `createMemoryI18n`, `HttpDispatcher.handleI18n()`, and + `I18nServicePlugin` route handlers. +- **Auto-detection of I18nServicePlugin** — Both `DevPlugin` and CLI `serve` command now + automatically detect `translations`/`i18n` config in the stack definition and register + `I18nServicePlugin` from `@objectstack/service-i18n` when available. Falls back to + the core in-memory i18n (with locale resolution) if the package is not installed. +- **Enhanced i18n diagnostics** — `AppPlugin` now emits clear warnings when: + - Translations exist but no i18n service is registered (guides user to add the plugin). + - Translations are loaded into a fallback/stub i18n service (recommends production plugin). +- **i18n locale fallback in REST routes** — `HttpDispatcher.handleI18n()` translations and labels + endpoints now resolve locale codes via fallback when exact match returns empty translations. + The response includes `requestedLocale` when a fallback was used. + +### Changed +- **DevPlugin i18n stub** — Replaced the duplicate `createI18nStub()` in `DevPlugin` with + `createMemoryI18n` from `@objectstack/core`, ensuring locale fallback works consistently + in dev mode. DevPlugin now tries `I18nServicePlugin` before the stub when stack has translations. +- `createMemoryI18n` now uses `resolveLocale()` internally for `t()` and `getTranslations()`, + enabling locale fallback (e.g. `zh` → `zh-CN`) without any plugin changes. +- CLI `serve` command now auto-registers `I18nServicePlugin` when config has translations/i18n, + mirroring DevPlugin's auto-detection behavior for production environments. + ### Changed - **i18n route self-registration** — Moved i18n REST endpoint registration from `RestServer` to `I18nServicePlugin` (and kernel fallback). The i18n plugin now self-registers `/api/v1/i18n/*` diff --git a/packages/core/src/fallbacks/fallbacks.test.ts b/packages/core/src/fallbacks/fallbacks.test.ts index 3f335b4e41..4ef92f8817 100644 --- a/packages/core/src/fallbacks/fallbacks.test.ts +++ b/packages/core/src/fallbacks/fallbacks.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { createMemoryCache } from './memory-cache'; import { createMemoryQueue } from './memory-queue'; import { createMemoryJob } from './memory-job'; -import { createMemoryI18n } from './memory-i18n'; +import { createMemoryI18n, resolveLocale } from './memory-i18n'; import { CORE_FALLBACK_FACTORIES } from './index'; describe('CORE_FALLBACK_FACTORIES', () => { @@ -222,3 +222,60 @@ describe('createMemoryI18n', () => { expect(i18n.getTranslations('en')).toEqual({ hello: 'Hello', bye: 'Goodbye' }); }); }); + +describe('resolveLocale', () => { + it('should return exact match', () => { + expect(resolveLocale('zh-CN', ['en', 'zh-CN', 'ja'])).toBe('zh-CN'); + }); + + it('should return case-insensitive match', () => { + expect(resolveLocale('zh-cn', ['en', 'zh-CN'])).toBe('zh-CN'); + expect(resolveLocale('EN-US', ['en-US', 'zh-CN'])).toBe('en-US'); + }); + + it('should return base language match', () => { + expect(resolveLocale('zh-TW', ['en', 'zh'])).toBe('zh'); + }); + + it('should return variant expansion (zh → zh-CN)', () => { + expect(resolveLocale('zh', ['en', 'zh-CN', 'zh-TW'])).toBe('zh-CN'); + }); + + it('should return undefined for no match', () => { + expect(resolveLocale('fr', ['en', 'zh-CN'])).toBeUndefined(); + }); + + it('should return undefined for empty available locales', () => { + expect(resolveLocale('en', [])).toBeUndefined(); + }); + + it('should handle es → es-ES expansion', () => { + expect(resolveLocale('es', ['en', 'es-ES', 'fr'])).toBe('es-ES'); + }); +}); + +describe('createMemoryI18n locale fallback', () => { + it('should resolve translations via locale fallback (zh → zh-CN)', () => { + const i18n = createMemoryI18n(); + i18n.loadTranslations('zh-CN', { hello: '你好' }); + expect(i18n.getTranslations('zh')).toEqual({ hello: '你好' }); + }); + + it('should resolve translations via case-insensitive fallback (zh-cn → zh-CN)', () => { + const i18n = createMemoryI18n(); + i18n.loadTranslations('zh-CN', { hello: '你好' }); + expect(i18n.getTranslations('zh-cn')).toEqual({ hello: '你好' }); + }); + + it('should translate via locale fallback (zh → zh-CN)', () => { + const i18n = createMemoryI18n(); + i18n.loadTranslations('zh-CN', { greeting: '你好世界' }); + expect(i18n.t('greeting', 'zh')).toBe('你好世界'); + }); + + it('should still fall back to default locale when no locale match at all', () => { + const i18n = createMemoryI18n(); + i18n.loadTranslations('en', { hello: 'Hello' }); + expect(i18n.t('hello', 'ja')).toBe('Hello'); + }); +}); diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 191f1acdc1..70319e8d2a 100644 --- a/packages/plugins/plugin-dev/package.json +++ b/packages/plugins/plugin-dev/package.json @@ -27,7 +27,8 @@ "@objectstack/plugin-auth": "workspace:^", "@objectstack/plugin-hono-server": "workspace:^", "@objectstack/plugin-security": "workspace:^", - "@objectstack/rest": "workspace:^" + "@objectstack/rest": "workspace:^", + "@objectstack/service-i18n": "workspace:^" }, "peerDependenciesMeta": { "@objectstack/driver-memory": { @@ -50,6 +51,9 @@ }, "@objectstack/rest": { "optional": true + }, + "@objectstack/service-i18n": { + "optional": true } }, "devDependencies": { @@ -60,6 +64,7 @@ "@objectstack/plugin-hono-server": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/rest": "workspace:*", + "@objectstack/service-i18n": "workspace:*", "@types/node": "^25.3.5", "typescript": "^5.0.0", "vitest": "^4.0.18" diff --git a/packages/runtime/src/app-plugin.test.ts b/packages/runtime/src/app-plugin.test.ts index 777e966dbd..a0c86f6710 100644 --- a/packages/runtime/src/app-plugin.test.ts +++ b/packages/runtime/src/app-plugin.test.ts @@ -182,10 +182,9 @@ describe('AppPlugin', () => { const plugin = new AppPlugin(bundle); await plugin.start!(mockContext); - // Should log debug but not throw - expect(mockContext.logger.debug).toHaveBeenCalledWith( - expect.stringContaining('No i18n service registered'), - expect.any(Object) + // Should log warning (translations exist but no i18n service) and not throw + expect(mockContext.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('no i18n service is registered') ); }); @@ -202,10 +201,9 @@ describe('AppPlugin', () => { const plugin = new AppPlugin(bundle); await plugin.start!(mockContext); - // Should log debug but not throw - expect(mockContext.logger.debug).toHaveBeenCalledWith( - expect.stringContaining('No i18n service registered'), - expect.any(Object) + // Should log warning (translations exist but no i18n service) and not throw + expect(mockContext.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('no i18n service is registered') ); }); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 20453ddd28..1556cbb891 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -267,7 +267,7 @@ export class AppPlugin implements Plugin { } // Emit diagnostic when the active i18n service is a fallback/stub - const svcAny = i18nService as Record; + const svcAny = i18nService as unknown as Record; if (svcAny._fallback || svcAny._dev) { ctx.logger.info( `[i18n] Loaded ${loadedLocales} locale(s) into in-memory i18n fallback for "${appId}". ` + diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 0bea9bc5b1..a8c583f0f4 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -953,6 +953,37 @@ describe('HttpDispatcher', () => { expect(result.handled).toBe(true); expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN', 'ja']); }); + + it('should resolve locale via fallback (zh → zh-CN) for translations', async () => { + // Override mock to be locale-aware: only 'zh-CN' has data, 'zh' returns empty + mockI18nService.getTranslations = vi.fn().mockImplementation((locale: string) => { + if (locale === 'zh-CN') return { 'o.task.label': '任务' }; + return {}; + }); + + const result = await dispatcher.handleI18n('/translations/zh', 'GET', {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + const data = result.response?.body?.data; + expect(data.locale).toBe('zh-CN'); + expect(data.requestedLocale).toBe('zh'); + expect(data.translations).toEqual({ 'o.task.label': '任务' }); + }); + + it('should resolve locale via case-insensitive fallback (ZH-CN → zh-CN) for translations', async () => { + // Override mock to be locale-aware: 'ZH-CN' returns empty, 'zh-CN' has data + mockI18nService.getTranslations = vi.fn().mockImplementation((locale: string) => { + if (locale === 'zh-CN') return { 'o.task.label': '任务' }; + return {}; + }); + + const result = await dispatcher.handleI18n('/translations/ZH-CN', 'GET', {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + const data = result.response?.body?.data; + expect(data.locale).toBe('zh-CN'); + expect(data.translations).toEqual({ 'o.task.label': '任务' }); + }); }); // ═══════════════════════════════════════════════════════════════ diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 75e8c75f76..c4ff50a151 100644 --- a/packages/services/service-i18n/package.json +++ b/packages/services/service-i18n/package.json @@ -9,8 +9,8 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" + "import": "./dist/index.js", + "require": "./dist/index.cjs" } }, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 060f9940ce..04c1a20f70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -757,6 +757,9 @@ importers: '@objectstack/runtime': specifier: workspace:* version: link:../../runtime + '@objectstack/service-i18n': + specifier: workspace:* + version: link:../../services/service-i18n '@types/node': specifier: ^25.3.5 version: 25.3.5 From 8e31dd98f1258a07040504fd7f401b39e2b21512 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:51:01 +0000 Subject: [PATCH 4/4] fix(cli): use dynamic import variable to avoid tsc resolution of optional @objectstack/service-i18n The CLI package uses tsc (not tsup) for building, so a static string literal in `import('@objectstack/service-i18n')` causes TS2307 because the package isn't in CLI's dependencies. Using a variable indirection prevents tsc from attempting module resolution at compile time. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/cli/src/commands/serve.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 00f8f271b3..1cc180203d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -220,7 +220,9 @@ export default class Serve extends Command { ); if (!hasI18nPlugin && configHasTranslations) { try { - const { I18nServicePlugin } = await import('@objectstack/service-i18n'); + // Dynamic import with variable to prevent tsc from resolving the optional package + const i18nPkg = '@objectstack/service-i18n'; + const { I18nServicePlugin } = await import(/* webpackIgnore: true */ i18nPkg); const i18nCfg = config.i18n || config.manifest?.i18n || {}; await kernel.use(new I18nServicePlugin({ defaultLocale: i18nCfg.defaultLocale,