Skip to content

Commit f0e1181

Browse files
authored
Merge pull request #1107 from objectstack-ai/copilot/unify-i18n-plugin-loading
2 parents 7129017 + 860c8a1 commit f0e1181

File tree

6 files changed

+105
-45
lines changed

6 files changed

+105
-45
lines changed

CHANGELOG.md

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

1010
### Added
1111

12+
- **Unified i18n Plugin Loading & Translation Injection** (`examples/crm`, `apps/console`): Unified the i18n loading mechanism so that both server and MSW/mock environments use the same translation pipeline. CRM's `objectstack.config.ts` now declares its translations via `i18n: { namespace: 'crm', translations: crmLocales }`. The shared config (`objectstack.shared.ts`) merges i18n bundles from all composed stacks. `createKernel` registers an i18n kernel service from the config bundles and auto-generates the `/api/v1/i18n/translations/:lang` MSW handler, returning translations in the standard `{ data: { locale, translations } }` spec envelope. Removed all manually-maintained i18n custom handlers and duplicate `loadAppLocale` functions from `browser.ts` and `server.ts`. The broker shim now supports `i18n.getTranslations` for server-side dispatch.
13+
1214
- **ObjectDataTable: columns now support `string[]` shorthand** (`@object-ui/plugin-dashboard`): `ObjectDataTable` now normalizes `columns` entries so that both `string[]` (e.g. `['name', 'close_date']`) and `object[]` formats are accepted. String entries are automatically converted to `{ header, accessorKey }` objects with title-cased headers derived from snake_case and camelCase field names. Previously, passing a `string[]` caused the downstream `data-table` renderer to crash when accessing `col.accessorKey` on a plain string. Mixed arrays (some strings, some objects) are also handled correctly. Includes 8 new unit tests.
1315

1416
### Fixed

apps/console/objectstack.shared.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ const allConfigs = [crmConfig, todoConfig, kitchenSinkConfig];
2424
// so we collect data from all stacks before composing).
2525
const allData = allConfigs.flatMap((c: any) => c.manifest?.data || c.data || []);
2626

27+
// Aggregate i18n bundles from all stacks that declare an i18n section.
28+
// Each bundle carries a namespace (e.g. 'crm') and per-language translations.
29+
const i18nBundles = allConfigs
30+
.map((c: any) => c.i18n)
31+
.filter((i: any) => i?.namespace && i?.translations);
32+
2733
// Protocol-level composition via @objectstack/spec: handles object dedup,
2834
// array concatenation, actions→objects mapping, and manifest selection.
2935
const composed = composeStacks(allConfigs as any[], { objectConflict: 'override' }) as any;
@@ -89,6 +95,9 @@ export const sharedConfig = {
8995
name: '@object-ui/console',
9096
data: allData,
9197
},
98+
i18n: {
99+
bundles: i18nBundles,
100+
},
92101
plugins: [],
93102
datasources: [
94103
{

apps/console/src/mocks/browser.ts

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
* via MSW. This ensures filter/sort/top/pagination work identically to
77
* server mode.
88
*
9+
* i18n translations are resolved automatically by createKernel from the
10+
* `i18n.bundles` field in the app config — no manual handler required.
11+
*
912
* This pattern follows @objectstack/studio — see https://github.com/objectstack-ai/spec
1013
*/
1114

12-
import { http, HttpResponse } from 'msw';
1315
import { setupWorker } from 'msw/browser';
1416
import { ObjectKernel } from '@objectstack/runtime';
1517
import { InMemoryDriver } from '@objectstack/driver-memory';
@@ -23,22 +25,6 @@ let driver: InMemoryDriver | null = null;
2325
let mswPlugin: MSWPlugin | null = null;
2426
let worker: ReturnType<typeof setupWorker> | null = null;
2527

26-
/**
27-
* Load application-specific locale bundles for the i18n API endpoint.
28-
* In this mock environment, loads translations from installed example packages.
29-
* Returns a flat translation resource for the given language code.
30-
*/
31-
async function loadAppLocale(lang: string): Promise<Record<string, unknown>> {
32-
try {
33-
const { crmLocales } = await import('@object-ui/example-crm');
34-
const translations = (crmLocales as Record<string, any>)[lang];
35-
if (!translations) return {};
36-
return { crm: translations };
37-
} catch {
38-
return {};
39-
}
40-
}
41-
4228
export async function startMockServer() {
4329
// Polyfill process.on for ObjectKernel in browser environment
4430
try {
@@ -65,12 +51,6 @@ export async function startMockServer() {
6551
customHandlers: [
6652
// Mock auth endpoints (better-auth compatible)
6753
...createAuthHandlers('/api/v1/auth'),
68-
// Serve i18n translation bundles via API
69-
http.get('/api/v1/i18n/translations/:lang', async ({ params }) => {
70-
const lang = params.lang as string;
71-
const resources = await loadAppLocale(lang);
72-
return HttpResponse.json(resources);
73-
}),
7454
],
7555
},
7656
});

apps/console/src/mocks/createKernel.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { InMemoryDriver, MemoryAnalyticsService } from '@objectstack/driver-memo
1818
import { MSWPlugin } from '@objectstack/plugin-msw';
1919
import type { MSWPluginOptions } from '@objectstack/plugin-msw';
2020
import type { Cube } from '@objectstack/spec/data';
21+
import { http, HttpResponse } from 'msw';
2122

2223
export interface KernelOptions {
2324
/** Application configuration (defineStack output) */
@@ -111,6 +112,15 @@ async function installBrokerShim(kernel: ObjectKernel): Promise<void> {
111112
}
112113
}
113114

115+
// i18n service calls (e.g. i18n.getTranslations)
116+
if (service === 'i18n') {
117+
let i18nService: any;
118+
try { i18nService = await kernel.getService('i18n'); } catch { /* noop */ }
119+
if (i18nService) {
120+
if (method === 'getTranslations') return i18nService.getTranslations(params?.lang);
121+
}
122+
}
123+
114124
throw new Error(`[BrokerShim] Unhandled action: ${action}`);
115125
},
116126
};
@@ -247,6 +257,38 @@ function buildCubesFromConfig(appConfig: any): Cube[] {
247257
return cubes;
248258
}
249259

260+
/** Per-language translation map: language code → translation tree. */
261+
type TranslationMap = Record<string, Record<string, unknown>>;
262+
263+
/** A named translation bundle as declared in a stack's i18n config. */
264+
interface I18nBundle {
265+
namespace: string;
266+
translations: TranslationMap;
267+
}
268+
269+
/**
270+
* Resolve translations for a given language from the i18n bundles
271+
* declared in the application configuration.
272+
*
273+
* Each bundle carries a `namespace` (e.g. "crm") and a `translations`
274+
* map keyed by language code. The resolved output nests each bundle's
275+
* translations under its namespace so that i18next sees keys like
276+
* `crm.objects.account.label`.
277+
*/
278+
function resolveI18nTranslations(
279+
bundles: I18nBundle[],
280+
lang: string,
281+
): Record<string, unknown> {
282+
const merged: Record<string, unknown> = {};
283+
for (const bundle of bundles) {
284+
const langTranslations = bundle.translations[lang];
285+
if (langTranslations) {
286+
merged[bundle.namespace] = langTranslations;
287+
}
288+
}
289+
return merged;
290+
}
291+
250292
/**
251293
* Create and bootstrap an ObjectStack kernel with in-memory driver.
252294
*
@@ -282,14 +324,52 @@ export async function createKernel(options: KernelOptions): Promise<KernelResult
282324
generateSql: (query: any) => memoryAnalytics.generateSql(query),
283325
});
284326

327+
// ── i18n service registration ──────────────────────────────────────
328+
// Read translation bundles from the app config (populated by each stack's
329+
// `i18n: { namespace, translations }` field and merged via sharedConfig).
330+
// This ensures both MSW/mock and server modes share the same translation
331+
// resolution pipeline — no manual per-environment i18n handlers required.
332+
const i18nBundles: I18nBundle[] = appConfig.i18n?.bundles ?? [];
333+
334+
if (i18nBundles.length > 0) {
335+
kernel.registerService('i18n', {
336+
getTranslations: (lang: string) => ({
337+
locale: lang,
338+
translations: resolveI18nTranslations(i18nBundles, lang),
339+
}),
340+
});
341+
}
342+
285343
let mswPlugin: MSWPlugin | undefined;
286344
if (mswOptions) {
287345
// Install a protocol-based broker shim BEFORE MSWPlugin's start phase
288346
// so that HttpDispatcher (inside MSWPlugin) can resolve data/metadata
289347
// calls without requiring a full Moleculer broker.
290348
await installBrokerShim(kernel);
291349

292-
mswPlugin = new MSWPlugin(mswOptions);
350+
// Auto-inject the i18n REST handler when translations are declared in
351+
// the app config, so callers (browser.ts / server.ts) no longer need
352+
// to manually construct a custom i18n MSW route.
353+
const effectiveOptions = { ...mswOptions };
354+
if (i18nBundles.length > 0) {
355+
const baseUrl = mswOptions.baseUrl ?? '';
356+
const i18nHandler = http.get(
357+
`${baseUrl}/i18n/translations/:lang`,
358+
({ params }) => {
359+
const lang = params.lang as string;
360+
const translations = resolveI18nTranslations(i18nBundles, lang);
361+
return HttpResponse.json({
362+
data: { locale: lang, translations },
363+
});
364+
},
365+
);
366+
effectiveOptions.customHandlers = [
367+
i18nHandler,
368+
...(mswOptions.customHandlers ?? []),
369+
];
370+
}
371+
372+
mswPlugin = new MSWPlugin(effectiveOptions);
293373
await kernel.use(mswPlugin);
294374
}
295375

apps/console/src/mocks/server.ts

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
* full ObjectStack protocol, so filter/sort/top/pagination work
77
* identically to server mode.
88
*
9+
* i18n translations are resolved automatically by createKernel from the
10+
* `i18n.bundles` field in the app config — no manual handler required.
11+
*
912
* This pattern follows @objectstack/studio — see https://github.com/objectstack-ai/spec
1013
*/
1114

12-
import { http, HttpResponse } from 'msw';
1315
import { ObjectKernel } from '@objectstack/runtime';
1416
import { InMemoryDriver } from '@objectstack/driver-memory';
1517
import { setupServer } from 'msw/node';
@@ -23,21 +25,6 @@ let driver: InMemoryDriver | null = null;
2325
let mswPlugin: MSWPlugin | null = null;
2426
let server: ReturnType<typeof setupServer> | null = null;
2527

26-
/**
27-
* Load application-specific locale bundles for the i18n API endpoint.
28-
* In this mock environment, loads translations from installed example packages.
29-
*/
30-
async function loadAppLocale(lang: string): Promise<Record<string, unknown>> {
31-
try {
32-
const { crmLocales } = await import('@object-ui/example-crm');
33-
const translations = (crmLocales as Record<string, any>)[lang];
34-
if (!translations) return {};
35-
return { crm: translations };
36-
} catch {
37-
return {};
38-
}
39-
}
40-
4128
export async function startMockServer() {
4229
if (kernel) {
4330
console.log('[MSW] ObjectStack Runtime already initialized');
@@ -56,11 +43,6 @@ export async function startMockServer() {
5643
customHandlers: [
5744
// Mock auth endpoints (better-auth compatible)
5845
...createAuthHandlers('/api/v1/auth'),
59-
http.get('/api/v1/i18n/translations/:lang', async ({ params }) => {
60-
const lang = params.lang as string;
61-
const resources = await loadAppLocale(lang);
62-
return HttpResponse.json(resources);
63-
}),
6446
],
6547
},
6648
});

examples/crm/objectstack.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { UserObject } from './src/objects/user.object';
99
import { ProjectObject } from './src/objects/project.object';
1010
import { EventObject } from './src/objects/event.object';
1111
import { OpportunityContactObject } from './src/objects/opportunity_contact.object';
12+
import { crmLocales } from './src/i18n';
1213
import { AccountView } from './src/views/account.view';
1314
import { ContactView } from './src/views/contact.view';
1415
import { OpportunityView } from './src/views/opportunity.view';
@@ -118,5 +119,11 @@ export default defineStack({
118119
OpportunityContactData,
119120
]
120121
},
122+
i18n: {
123+
defaultLocale: 'en',
124+
supportedLocales: ['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar'],
125+
namespace: 'crm',
126+
translations: crmLocales,
127+
} as any,
121128
plugins: [],
122129
}, { strict: false }); // Defer validation to `objectstack compile` CLI

0 commit comments

Comments
 (0)