Skip to content

Commit 31ce8e9

Browse files
Copilothotlong
andcommitted
feat: AppPlugin i18n auto-loading, enhanced tests, and documentation
- AppPlugin.start() auto-loads translation bundles into i18n service - Added 10 new tests: AppPlugin i18n loading + environment consistency - Added i18n service registration guide to kernel-services.mdx - Updated CHANGELOG.md with Added section Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 58df19f commit 31ce8e9

5 files changed

Lines changed: 352 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Updated all framework adapters (Hono, Express, Fastify, Next.js, NestJS, Nuxt, SvelteKit),
1717
the dispatcher plugin, and the MSW plugin to `await` the now-async `getDiscoveryInfo()`.
1818

19+
### Added
20+
- **AppPlugin i18n auto-loading**`AppPlugin` now automatically loads translation bundles from
21+
app configs (`translations` array) into the kernel's i18n service during the `start` phase,
22+
coordinating i18n data loading across server/dev/mock environments.
23+
- i18n service registration guide in `content/docs/guides/kernel-services.mdx` documenting
24+
service registration patterns, discovery consistency, and AppPlugin auto-loading behavior.
25+
1926
### Changed
2027
- Updated ROADMAP.md for v3.0 release preparation with full codebase scan results
2128
- Audited all @deprecated items: 14 in spec, 9 in runtime packages (23 total)

content/docs/guides/kernel-services.mdx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,68 @@ Trigger engine, event triggers from ObjectQL hooks, flow executor, scheduled tri
246246
### 11. i18n — 3 methods
247247
`getLocales`, `getTranslations`, `getFieldLabels`
248248

249+
**Service Name**: `i18n` · **Criticality**: `optional`
250+
**Implementations**: `@objectstack/service-i18n` (production — file-based) · Dev Plugin (in-memory stub)
251+
**Route Mount**: `/api/v1/i18n`
252+
**Contract**: `II18nService` in `@objectstack/spec/contracts`
253+
254+
#### Service Registration
255+
256+
The i18n service is registered by a **plugin** during the `init` phase:
257+
258+
| Environment | Provider | Registration |
259+
|:------------|:---------|:-------------|
260+
| **Production** | `I18nServicePlugin` | File-based `FileI18nAdapter` loads JSON locale files from disk |
261+
| **Development** | `DevPlugin` | In-memory Map-backed stub, supports `loadTranslations()` |
262+
| **Mock / MSW** | `MswPlugin` | Routes via `HttpDispatcher.dispatch()` catch-all — requires one of the above |
263+
264+
```typescript
265+
// Production
266+
kernel.use(new I18nServicePlugin({ defaultLocale: 'en', localesDir: './i18n' }));
267+
268+
// Development (automatic — DevPlugin registers i18n stub for all 17 core services)
269+
kernel.use(new DevPlugin());
270+
```
271+
272+
#### Discovery & Handler Consistency
273+
274+
The Discovery API (`/api/v1` or `/.well-known/objectstack`) and the i18n route handler (`/api/v1/i18n/*`) both use the **same async resolution chain** to detect i18n availability:
275+
276+
```
277+
getServiceAsync() → getService() → context.getService() → services Map
278+
```
279+
280+
This ensures that `discovery.services.i18n.status` always matches the actual runtime behavior — a service registered via any mechanism (sync Map, async factory, or context) will be reported correctly in both places.
281+
282+
The `locale` field in the discovery response is populated from the actual i18n service:
283+
- `locale.default` — from `i18nService.getDefaultLocale()` (falls back to `'en'`)
284+
- `locale.supported` — from `i18nService.getLocales()` (falls back to `[default]`)
285+
286+
#### AppPlugin Auto-Loading
287+
288+
When an app bundle includes an `i18n` config and `translations` array, `AppPlugin` automatically loads the translation data into the i18n service during the `start` phase:
289+
290+
```typescript
291+
export default defineStack({
292+
manifest: { id: 'com.example.crm', namespace: 'crm' },
293+
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
294+
translations: [CrmTranslations], // TranslationBundle[]
295+
});
296+
```
297+
298+
AppPlugin will:
299+
1. Set the default locale via `i18nService.setDefaultLocale()`
300+
2. Call `i18nService.loadTranslations(locale, data)` for each locale in every bundle
301+
3. Skip gracefully if no i18n service is registered (no errors, just a debug log)
302+
303+
#### REST API Endpoints
304+
305+
| Method | Path | Description |
306+
|:-------|:-----|:------------|
307+
| `GET` | `/api/v1/i18n/locales` | List available locales |
308+
| `GET` | `/api/v1/i18n/translations/:locale` | Get all translations for a locale |
309+
| `GET` | `/api/v1/i18n/labels/:object/:locale` | Get translated field labels for an object |
310+
249311
---
250312

251313
## 12–17. Infrastructure Services ❌ Plugin Required

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,115 @@ describe('AppPlugin', () => {
9999
expect.any(Object)
100100
);
101101
});
102+
103+
// ═══════════════════════════════════════════════════════════════
104+
// i18n translation auto-loading
105+
// ═══════════════════════════════════════════════════════════════
106+
107+
describe('i18n translation loading', () => {
108+
let mockI18n: any;
109+
let mockQL: any;
110+
111+
beforeEach(() => {
112+
mockI18n = {
113+
loadTranslations: vi.fn(),
114+
setDefaultLocale: vi.fn(),
115+
getLocales: vi.fn().mockReturnValue([]),
116+
getDefaultLocale: vi.fn().mockReturnValue('en'),
117+
};
118+
mockQL = { registry: {} };
119+
120+
vi.mocked(mockContext.getService).mockImplementation((name: string) => {
121+
if (name === 'objectql') return mockQL;
122+
if (name === 'i18n') return mockI18n;
123+
return undefined;
124+
});
125+
});
126+
127+
it('should auto-load translations from bundle into i18n service', async () => {
128+
const bundle = {
129+
id: 'com.test.i18n',
130+
translations: [
131+
{
132+
en: { objects: { task: { label: 'Task' } } },
133+
'zh-CN': { objects: { task: { label: '任务' } } },
134+
},
135+
],
136+
};
137+
const plugin = new AppPlugin(bundle);
138+
await plugin.start!(mockContext);
139+
140+
expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { objects: { task: { label: 'Task' } } });
141+
expect(mockI18n.loadTranslations).toHaveBeenCalledWith('zh-CN', { objects: { task: { label: '任务' } } });
142+
});
143+
144+
it('should set default locale from i18n config', async () => {
145+
const bundle = {
146+
id: 'com.test.locale',
147+
i18n: { defaultLocale: 'zh-CN', supportedLocales: ['en', 'zh-CN'] },
148+
translations: [{ en: { messages: { hello: 'Hello' } } }],
149+
};
150+
const plugin = new AppPlugin(bundle);
151+
await plugin.start!(mockContext);
152+
153+
expect(mockI18n.setDefaultLocale).toHaveBeenCalledWith('zh-CN');
154+
});
155+
156+
it('should skip translation loading when i18n service is not registered', async () => {
157+
vi.mocked(mockContext.getService).mockImplementation((name: string) => {
158+
if (name === 'objectql') return mockQL;
159+
return undefined; // No i18n service
160+
});
161+
162+
const bundle = {
163+
id: 'com.test.noi18n',
164+
translations: [{ en: { messages: { hello: 'Hello' } } }],
165+
};
166+
const plugin = new AppPlugin(bundle);
167+
await plugin.start!(mockContext);
168+
169+
// Should log debug but not throw
170+
expect(mockContext.logger.debug).toHaveBeenCalledWith(
171+
expect.stringContaining('No i18n service registered'),
172+
expect.any(Object)
173+
);
174+
});
175+
176+
it('should handle bundle with no translations gracefully', async () => {
177+
const bundle = { id: 'com.test.notrans' };
178+
const plugin = new AppPlugin(bundle);
179+
await plugin.start!(mockContext);
180+
181+
expect(mockI18n.loadTranslations).not.toHaveBeenCalled();
182+
});
183+
184+
it('should load translations from nested manifest.translations', async () => {
185+
const bundle = {
186+
manifest: {
187+
id: 'com.test.nested',
188+
translations: [
189+
{ en: { messages: { save: 'Save' } } },
190+
],
191+
},
192+
};
193+
const plugin = new AppPlugin(bundle);
194+
await plugin.start!(mockContext);
195+
196+
expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { messages: { save: 'Save' } });
197+
});
198+
199+
it('should load multiple translation bundles', async () => {
200+
const bundle = {
201+
id: 'com.test.multi',
202+
translations: [
203+
{ en: { objects: { task: { label: 'Task' } } } },
204+
{ en: { objects: { contact: { label: 'Contact' } } }, 'ja-JP': { objects: { contact: { label: '連絡先' } } } },
205+
],
206+
};
207+
const plugin = new AppPlugin(bundle);
208+
await plugin.start!(mockContext);
209+
210+
expect(mockI18n.loadTranslations).toHaveBeenCalledTimes(3);
211+
});
212+
});
102213
});

packages/runtime/src/app-plugin.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { Plugin, PluginContext } from '@objectstack/core';
44
import { SeedLoaderService } from './seed-loader.js';
5-
import type { IMetadataService } from '@objectstack/spec/contracts';
5+
import type { IMetadataService, II18nService } from '@objectstack/spec/contracts';
66

77
/**
88
* AppPlugin
@@ -12,6 +12,7 @@ import type { IMetadataService } from '@objectstack/spec/contracts';
1212
* Responsibilities:
1313
* 1. Register App Manifest as a service (for ObjectQL discovery)
1414
* 2. Execute Runtime `onEnable` hook (for code logic)
15+
* 3. Auto-load i18n translation bundles into the kernel's i18n service
1516
*/
1617
export class AppPlugin implements Plugin {
1718
name: string;
@@ -102,6 +103,11 @@ export class AppPlugin implements Plugin {
102103
ctx.logger.debug('No runtime.onEnable function found', { appId });
103104
}
104105

106+
// ── i18n Translation Loading ─────────────────────────────────────
107+
// Auto-load translation bundles from the app config into the
108+
// kernel's i18n service, so discovery and handlers stay consistent.
109+
this.loadTranslations(ctx, appId);
110+
105111
// Data Seeding
106112
// Collect seed data from multiple locations (top-level `data` preferred, `manifest.data` for backward compat)
107113
const seedDatasets: any[] = [];
@@ -185,4 +191,54 @@ export class AppPlugin implements Plugin {
185191
}
186192
}
187193
}
194+
195+
/**
196+
* Auto-load i18n translation bundles from the app config into the
197+
* kernel's i18n service. Handles both `translations` (array of
198+
* TranslationBundle) and `i18n` config (default locale, etc.).
199+
*
200+
* Gracefully skips when the i18n service is not registered —
201+
* this keeps AppPlugin resilient across server/dev/mock environments.
202+
*/
203+
private loadTranslations(ctx: PluginContext, appId: string): void {
204+
const i18nService = ctx.getService('i18n') as II18nService | undefined;
205+
if (!i18nService) {
206+
ctx.logger.debug('[i18n] No i18n service registered; skipping translation loading', { appId });
207+
return;
208+
}
209+
210+
// Apply i18n config (default locale, etc.)
211+
const i18nConfig = this.bundle.i18n || (this.bundle.manifest || this.bundle)?.i18n;
212+
if (i18nConfig?.defaultLocale && typeof i18nService.setDefaultLocale === 'function') {
213+
i18nService.setDefaultLocale(i18nConfig.defaultLocale);
214+
ctx.logger.debug('[i18n] Set default locale', { appId, locale: i18nConfig.defaultLocale });
215+
}
216+
217+
// Collect translation bundles from top-level and legacy locations
218+
const bundles: Array<Record<string, unknown>> = [];
219+
if (Array.isArray(this.bundle.translations)) {
220+
bundles.push(...this.bundle.translations);
221+
}
222+
const manifest = this.bundle.manifest || this.bundle;
223+
if (manifest && Array.isArray(manifest.translations) && manifest.translations !== this.bundle.translations) {
224+
bundles.push(...manifest.translations);
225+
}
226+
227+
if (bundles.length === 0) {
228+
return;
229+
}
230+
231+
let loadedLocales = 0;
232+
for (const bundle of bundles) {
233+
// Each bundle is a TranslationBundle: Record<locale, TranslationData>
234+
for (const [locale, data] of Object.entries(bundle)) {
235+
if (data && typeof data === 'object') {
236+
i18nService.loadTranslations(locale, data as Record<string, unknown>);
237+
loadedLocales++;
238+
}
239+
}
240+
}
241+
242+
ctx.logger.info('[i18n] Loaded translation bundles', { appId, bundles: bundles.length, locales: loadedLocales });
243+
}
188244
}

0 commit comments

Comments
 (0)