feat: implement core-i18n package with decentralized namespace support and integrate into landing and web apps
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
# Enterprise i18n Architecture (`@repo/core-i18n`)
|
||||
|
||||
A highly decoupled, type-safe internationalization engine for the Eigen Monorepo.
|
||||
|
||||
It uses a **Hybrid Namespace Strategy**:
|
||||
1. **Centralized Engine**: Setup, local persistence (`@repo/core-storage`), and global words (`common`).
|
||||
2. **Decentralized Dictionaries**: Feature-specific translations (`booking`, `billing`) live inside the application modules and are lazy-loaded.
|
||||
|
||||
This architecture strictly adheres to **Inversion of Control (IoC)**. The core engine handles local state and performance, but leaves API and networking decisions entirely to the consuming applications.
|
||||
|
||||
---
|
||||
|
||||
## 1. App-Level Setup (Bootstrap)
|
||||
|
||||
Initialize the engine *before* your React application mounts to prevent UI flashing.
|
||||
|
||||
```tsx
|
||||
// apps/web/src/main.tsx
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { setupI18n } from '@repo/core-i18n';
|
||||
import App from './app';
|
||||
|
||||
async function bootstrap() {
|
||||
// Synchronously reads preferred language from storage & inits i18next
|
||||
await setupI18n();
|
||||
|
||||
createRoot(document.getElementById('app')!).render(
|
||||
<StrictMode><App /></StrictMode>,
|
||||
);
|
||||
}
|
||||
bootstrap();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Module-Level Setup (Decentralized Dictionaries)
|
||||
|
||||
Dictionaries live right next to the UI components that use them.
|
||||
|
||||
### Folder Structure
|
||||
```text
|
||||
apps/web/src/apps/modules/booking/
|
||||
├── presentation/BookingTable.tsx
|
||||
└── locales/
|
||||
├── id/booking.json
|
||||
└── en/booking.json
|
||||
```
|
||||
|
||||
### Lazy Loading & Type Safety
|
||||
Register the namespace when the component mounts. To get native TypeScript autocomplete for nested keys (e.g., `header.title`), augment the global `react-i18next` types.
|
||||
|
||||
**1. Augment Types:**
|
||||
```ts
|
||||
// apps/web/src/types/i18next.d.ts
|
||||
import 'react-i18next';
|
||||
import type { resources as coreResources } from '@repo/core-i18n/src/setup';
|
||||
import bookingEn from '../apps/modules/booking/locales/en/booking.json';
|
||||
|
||||
declare module 'react-i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: 'common';
|
||||
resources: typeof coreResources['en'] & { booking: typeof bookingEn };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. Use in Component:**
|
||||
```tsx
|
||||
import { useEffect } from 'react';
|
||||
import { i18n, useTranslation } from '@repo/core-i18n';
|
||||
import bookingId from '../locales/id/booking.json';
|
||||
import bookingEn from '../locales/en/booking.json';
|
||||
|
||||
export default function BookingFeature() {
|
||||
const { t } = useTranslation(['common', 'booking']);
|
||||
|
||||
useEffect(() => {
|
||||
i18n.addResourceBundle('id', 'booking', bookingId, true, false);
|
||||
i18n.addResourceBundle('en', 'booking', bookingEn, true, false);
|
||||
}, []);
|
||||
|
||||
return <h1>{t('booking:header.title')}</h1>; // Autocomplete works!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Real-World Implementation Flow
|
||||
|
||||
The engine supports robust flows for authenticated apps, including Tenant Vocabulary Overrides and Backend Synchronization.
|
||||
|
||||
### A. The Tenant Override Flow (After Login)
|
||||
|
||||
If "Company A" calls "Purchasing" -> "Procurement", they shouldn't need a custom build. The backend returns an override config, and the frontend dynamically merges it using `applyTenantOverrides`.
|
||||
|
||||
```tsx
|
||||
// Example inside an AuthProvider or Post-Login useEffect
|
||||
import { useEffect } from 'react';
|
||||
import { applyTenantOverrides } from '@repo/core-i18n';
|
||||
import { api } from '@/api';
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
useEffect(() => {
|
||||
async function fetchTenantConfig() {
|
||||
try {
|
||||
// 1. Fetch tenant-specific overrides from the API
|
||||
const response = await api.get('/v1/tenant/i18n-config');
|
||||
|
||||
// 2. Inject into the engine.
|
||||
// `deep: true` ensures only provided keys are overridden.
|
||||
applyTenantOverrides(
|
||||
response.data.namespace,
|
||||
response.data.overrides
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch tenant configuration", err);
|
||||
}
|
||||
}
|
||||
|
||||
fetchTenantConfig();
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
### B. User Preference Sync (With Rollback)
|
||||
|
||||
When a logged-in user changes their language, we update the UI instantly, save it locally, and sync it to the backend. If the backend fails, the engine automatically rolls back.
|
||||
|
||||
```tsx
|
||||
import { changeLanguage } from '@repo/core-i18n';
|
||||
import { api } from '@/api';
|
||||
|
||||
const handleSwitch = async (newLng: string) => {
|
||||
try {
|
||||
await changeLanguage(newLng, async (lng) => {
|
||||
// The core engine waits for this Promise.
|
||||
// If it throws, the UI reverts to the previous language automatically.
|
||||
await api.patch('/v1/user/profile', { language: lng });
|
||||
});
|
||||
toast.success('Language saved!');
|
||||
} catch (err) {
|
||||
toast.error('Sync failed. Reverted to previous language.');
|
||||
}
|
||||
};
|
||||
```
|
||||
> [!NOTE]
|
||||
> For public pages (like `apps/landing`), simply call `changeLanguage('en')` without the callback function. It will update the UI and local storage instantly without hitting the network.
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend API Contract (For Backend Engineers)
|
||||
|
||||
To support Dynamic Tenant Overrides, the backend must expose an endpoint (e.g., `GET /v1/tenant/i18n-config`).
|
||||
|
||||
### Identification
|
||||
The backend **MUST identify the tenant via the `Authorization` (JWT) header**. The frontend will not send `tenantId` in the query payload to prevent spoofing.
|
||||
|
||||
### Expected JSON Response Format
|
||||
The response must match the structural shape of the frontend dictionary. Because the frontend uses a **Deep Merge** strategy, the backend **only needs to return the specific keys the tenant wishes to override**.
|
||||
|
||||
If the frontend dictionary has `header.title` and `header.subtitle`, and the backend only sends `header.title`, the `subtitle` will safely remain intact.
|
||||
|
||||
**Example Request:**
|
||||
`GET /v1/tenant/i18n-config`
|
||||
*(Authorization: Bearer eyJhbG...)*
|
||||
|
||||
**Expected Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"namespace": "booking",
|
||||
"overrides": {
|
||||
"module_name": "Procurement",
|
||||
"header": {
|
||||
"title": "Procurement List"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@repo/core-i18n",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./react-i18next": "./src/react-i18next.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-storage": "workspace:*",
|
||||
"@repo/utils": "workspace:*",
|
||||
"i18next": "^24.2.2",
|
||||
"react-i18next": "^15.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/react": "^19.0.8",
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { setupI18n, type SupportedLanguage } from './setup';
|
||||
export { changeLanguage, applyTenantOverrides } from './manager';
|
||||
export { useTranslation, Trans } from 'react-i18next';
|
||||
export { default as i18n } from 'i18next';
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"settings": "Settings",
|
||||
"loading": "Loading...",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Simpan",
|
||||
"cancel": "Batal",
|
||||
"success": "Sukses",
|
||||
"error": "Galat",
|
||||
"settings": "Pengaturan",
|
||||
"loading": "Memuat...",
|
||||
"delete": "Hapus",
|
||||
"edit": "Ubah"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import i18n from 'i18next';
|
||||
import { demoSecureStorage, StorageKey } from '@repo/core-storage';
|
||||
|
||||
/**
|
||||
* Changes the active language, saves the preference locally, and optionally syncs with the backend.
|
||||
*
|
||||
* @param newLng The new language code (e.g., 'en', 'id')
|
||||
* @param syncCallback An optional callback to sync the preference to the backend. It receives the new language and the previous language.
|
||||
*/
|
||||
export async function changeLanguage(
|
||||
newLng: string,
|
||||
syncCallback?: (newLng: string, prevLng: string) => Promise<void>
|
||||
): Promise<void> {
|
||||
const prevLng = i18n.language;
|
||||
|
||||
if (prevLng === newLng) return;
|
||||
|
||||
// 1. Update local storage & i18next optimistically
|
||||
await demoSecureStorage.setItem(StorageKey.LOCALE, newLng);
|
||||
await i18n.changeLanguage(newLng);
|
||||
|
||||
// 2. Trigger optional backend sync
|
||||
if (syncCallback) {
|
||||
try {
|
||||
await syncCallback(newLng, prevLng);
|
||||
} catch (error) {
|
||||
console.error('[i18n] Backend sync failed, rolling back language', error);
|
||||
// Rollback on failure
|
||||
await demoSecureStorage.setItem(StorageKey.LOCALE, prevLng);
|
||||
await i18n.changeLanguage(prevLng);
|
||||
throw error; // Rethrow so the caller can show an error toast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects tenant-specific vocabulary overrides dynamically at runtime.
|
||||
*
|
||||
* Uses a deep-merge strategy. Overrides are applied to the currently active language,
|
||||
* or across all loaded languages if needed.
|
||||
*
|
||||
* @param namespace The i18n namespace to override (e.g., 'common', 'booking')
|
||||
* @param overrides A deeply nested object containing the overridden string keys and values.
|
||||
* @param lng Specific language to override. Defaults to currently active language.
|
||||
*/
|
||||
export function applyTenantOverrides(
|
||||
namespace: string,
|
||||
overrides: Record<string, unknown>,
|
||||
lng?: string
|
||||
): void {
|
||||
const targetLng = lng || i18n.language;
|
||||
|
||||
// deep: true -> merges with existing keys rather than replacing the whole namespace
|
||||
// overwrite: true -> allows replacing existing specific keys
|
||||
i18n.addResourceBundle(targetLng, namespace, overrides, true, true);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import 'react-i18next';
|
||||
import type { resources } from './setup';
|
||||
|
||||
declare module 'react-i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: 'common';
|
||||
resources: typeof resources['en'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import { demoSecureStorage, StorageKey } from '@repo/core-storage';
|
||||
import commonEn from './locales/en/common.json';
|
||||
import commonId from './locales/id/common.json';
|
||||
|
||||
const DEFAULT_LANGUAGE = 'id';
|
||||
const SUPPORTED_LANGUAGES = ['en', 'id'] as const;
|
||||
|
||||
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
|
||||
export const resources = {
|
||||
en: { common: commonEn.common },
|
||||
id: { common: commonId.common },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Bootstraps the central i18n engine.
|
||||
*
|
||||
* This reads the preferred locale from secureStorage and initializes
|
||||
* i18next synchronously before React renders.
|
||||
*/
|
||||
export async function setupI18n(): Promise<void> {
|
||||
let initialLng = DEFAULT_LANGUAGE;
|
||||
try {
|
||||
const storedLng = await demoSecureStorage.getItem<string>(StorageKey.LOCALE);
|
||||
if (storedLng && SUPPORTED_LANGUAGES.includes(storedLng as SupportedLanguage)) {
|
||||
initialLng = storedLng;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[i18n] Failed to read locale from storage', err);
|
||||
}
|
||||
|
||||
await i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
lng: initialLng,
|
||||
fallbackLng: DEFAULT_LANGUAGE,
|
||||
defaultNS: 'common',
|
||||
interpolation: {
|
||||
escapeValue: false, // React already escapes values
|
||||
},
|
||||
});
|
||||
|
||||
// Apply initial language to the DOM for SEO/Accessibility
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = i18n.language;
|
||||
}
|
||||
|
||||
// Ensure DOM updates whenever the language changes later
|
||||
i18n.on('languageChanged', (lng) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = lng;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/library.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user