Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +14 to +15

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changelog entry claims resolveLocale() is used by I18nServicePlugin route handlers, but the current I18nServicePlugin.registerI18nRoutes() implementation returns translations for the requested locale without any fallback. Either update the plugin routes to use resolveLocale (and document behavior), or adjust this changelog text so it matches the actual implementation.

Suggested change
(`zh``zh-CN`). Used by `createMemoryI18n`, `HttpDispatcher.handleI18n()`, and
`I18nServicePlugin` route handlers.
(`zh``zh-CN`). Used by `createMemoryI18n` and `HttpDispatcher.handleI18n()`.

Copilot uses AI. Check for mistakes.
- **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

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two consecutive ### Changed sections in the Unreleased block, which makes the changelog harder to read and can confuse release tooling/readers. Consider merging these into a single ### Changed section (or renaming one of them if it’s meant to be a different category).

Suggested change
### Changed

Copilot uses AI. Check for mistakes.
- **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/*`
Expand Down
40 changes: 36 additions & 4 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,44 @@ 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'
);
Comment on lines +209 to +212

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasI18nPlugin only detects instantiated plugin objects. serve.ts also supports plugins declared as strings (package names) later in the same command, so a config that includes '@objectstack/service-i18n' as a string can lead to the i18n plugin being auto-registered here and then registered again when iterating over plugins. Update the detection to also handle string entries (e.g. exact match / substring), or check kernel service/plugin registration state before auto-registering.

Suggested change
const hasI18nPlugin = plugins.some(
(p: any) => p.name === 'com.objectstack.service.i18n'
|| p.constructor?.name === 'I18nServicePlugin'
);
const hasI18nPlugin = Array.isArray(plugins) && plugins.some((p: any) => {
if (!p) return false;
// String plugin declaration, e.g. '@objectstack/service-i18n'
if (typeof p === 'string') {
const normalized = p.toLowerCase();
return (
normalized === '@objectstack/service-i18n' ||
normalized.includes('service-i18n')
);
}
// Instantiated plugin object
return (
p.name === 'com.objectstack.service.i18n' ||
p.constructor?.name === 'I18nServicePlugin'
);
});

Copilot uses AI. Check for mistakes.
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 {
// 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,
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
Expand Down
59 changes: 58 additions & 1 deletion packages/core/src/fallbacks/fallbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
2 changes: 1 addition & 1 deletion packages/core/src/fallbacks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 52 additions & 3 deletions packages/core/src/fallbacks/memory-i18n.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,48 @@
// 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.
*
* Implements the II18nService contract with basic translate/load/getLocales
* 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() {
Expand All @@ -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<string, unknown> | 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, unknown>): 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;
Expand All @@ -40,7 +89,7 @@ export function createMemoryI18n() {
},

getTranslations(locale: string): Record<string, unknown> {
return translations.get(locale) ?? {};
return resolveTranslations(locale) ?? {};
},

loadTranslations(locale: string, data: Record<string, unknown>): void {
Expand Down
7 changes: 6 additions & 1 deletion packages/plugins/plugin-dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -50,6 +51,9 @@
},
"@objectstack/rest": {
"optional": true
},
"@objectstack/service-i18n": {
"optional": true
}
},
"devDependencies": {
Expand All @@ -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"
Expand Down
50 changes: 33 additions & 17 deletions packages/plugins/plugin-dev/src/dev-plugin.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<string, Record<string, unknown>>();
let defaultLocale = 'en';
const base = createMemoryI18n();
return {
...base,
_dev: true, _serviceName: 'i18n',
t(key: string, locale: string, params?: Record<string, unknown>): 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<string, unknown> { return translations.get(locale) ?? {}; },
loadTranslations(locale: string, data: Record<string, unknown>) { translations.set(locale, { ...translations.get(locale), ...data }); },
getLocales() { return [...translations.keys()]; },
getDefaultLocale() { return defaultLocale; },
setDefaultLocale(locale: string) { defaultLocale = locale; },
};
}

Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 6 additions & 8 deletions packages/runtime/src/app-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
);
});

Expand All @@ -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')
);
});

Expand Down
Loading
Loading