- Removed old validation and common language files for English and Indonesian. - Created new language files for English and Indonesian under the updated directory structure. - Updated setup to reflect new language file paths and changed default language to English. - Modified storage keys from 'locale' to 'language' for consistency. - Added new language files for various modules including bookmarks, history, navigation, and system settings. - Introduced new booking module language files for both English and Indonesian. - Implemented a login page component with basic structure.
11 KiB
i18n Architecture (@repo/core-i18n)
Architectural Foundation: i18next · react-i18next
Description: Hybrid namespace internationalization engine built on i18next, providing centralized common vocabularies with lazy-loaded feature dictionaries, tenant overrides, and backend sync with automatic rollback.
A highly decoupled, type-safe internationalization engine for the monorepo.
It uses a Hybrid Namespace Strategy:
- Centralized Engine: Setup, local persistence orchestration, and global words (
common). - 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, networking, and storage implementation decisions entirely to the consuming applications.
Overview Architecture
graph TD
%% ─── Styling Definitions (Dark-Mode Friendly Enterprise Palette) ───
classDef appEntity fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
classDef coreEngine fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#ffffff
classDef dataStore fill:#10b981,stroke:#047857,stroke-width:2px,color:#ffffff
classDef externalAPI fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#ffffff
%% ─── Subgraphs ───
subgraph Apps ["apps/* (App Autonomy)"]
UI([React Components])
DICT[[Feature Dictionaries: booking.json]]
end
subgraph Core ["@repo/core-i18n (Engine)"]
I18N[i18next Instance]
STORE[(core-storage)]
COMMON[Common Vocabulary]
end
subgraph Backend ["Backend API (External)"]
SYNC([Language Sync Endpoint])
TENANT([Tenant Config Endpoint])
end
%% ─── Flow & Relationships ───
UI ===>|uses useTranslation| I18N
DICT -.->|lazy loads| I18N
COMMON --->|preloads| I18N
I18N <===>|reads / persists| STORE
I18N --->|changeLanguage sync| SYNC
SYNC -.->|fails? rollback| I18N
TENANT -.->|applyTenantOverrides| I18N
%% ─── Apply Styles ───
class UI,DICT appEntity;
class I18N coreEngine;
class STORE,COMMON dataStore;
class SYNC,TENANT externalAPI;
%% ─── Subgraph Backgrounds (Transparent for Native GitHub Support) ───
style Apps fill:transparent,stroke:#3b82f6,stroke-width:2px,stroke-dasharray: 5 5
style Core fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5
style Backend fill:transparent,stroke:#f59e0b,stroke-width:2px,stroke-dasharray: 5 5
1. App-Level Setup (Bootstrap)
Initialize the engine before your React application mounts to prevent UI flashing. Provide an I18nStorageAdapter using Dependency Injection so the core engine can persist the user's language without being tightly coupled to a specific storage implementation.
// apps/web/src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n';
import { secureStorage, AppStorageKey } from './core/storage';
import App from './app';
async function bootstrap() {
// Synchronously reads preferred language from injected storage & inits i18next
await setupI18n({
storageAdapter: {
getLanguage: async () => {
const stored = await secureStorage.getItem(AppStorageKey.LANGUAGE);
return typeof stored === 'string' ? stored : null;
},
setLanguage: async (lng: string) => {
await secureStorage.setItem(AppStorageKey.LANGUAGE, lng);
},
},
});
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
apps/web/src/apps/modules/booking/
├── presentation/BookingTable.tsx
└── locales/
├── id/booking.json
└── en/booking.json
Namespace Registration & Context
To prevent unnecessary React re-renders and ensure dictionaries are loaded before the first paint, translations are registered at the module scope using registerModuleNamespace.
1. Register the Namespace in the Module Factory:
Call registerModuleNamespace at the top level of your module factory (e.g., index.tsx). This function is idempotent and safe to call outside the React render cycle. The namespace provided must match the translationNamespace defined in your module configuration.
// apps/web/src/apps/modules/booking/presentation/factory/index.tsx
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
import { registerModuleNamespace } from '@repo/core-i18n';
import { BookingModuleConfig } from '../../domain/constants';
import bookingId from '../locales/id/booking.json';
import bookingEn from '../locales/en/booking.json';
// Called once at import time — safe, idempotent, outside React render cycle.
registerModuleNamespace(BookingModuleConfig.translationNamespace, {
id: bookingId,
en: bookingEn,
});
export default function BookingModule() {
return <EnterpriseModuleProvider config={BookingModuleConfig}>{/* Routes and Pages */}</EnterpriseModuleProvider>;
}
2. Augment Types for Autocomplete:
To get native TypeScript autocomplete for nested keys (e.g., header.title), augment the global react-i18next types.
// 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 };
}
}
3. Consume in Components:
Inside your pages and components, use useEnterpriseModuleTranslationContext() instead of the raw useTranslation hook. The context automatically scopes the t function to your module's namespace and the global common namespace.
[!WARNING] > Best Practice: Do not hardcode the module namespace prefix (e.g.,
t('booking:header.title')) when usinguseEnterpriseModuleTranslationContext. The provider already scopes it. Simply uset('header.title'). You can still access global keys using the common prefix:t('common:edit').
// apps/web/src/apps/modules/booking/presentation/pages/booking.page.index.tsx
import { Title } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
export default function BookingPageIndex() {
const { t } = useEnterpriseModuleTranslationContext();
return (
<div>
<Title>{t('title')}</Title> {/* Automatically resolves to booking:title */}
<button>{t('common:edit')}</button> {/* Fallback to global common vocabulary */}
</div>
);
}
4. Dynamic Variables (Interpolation):
// booking.json
{
"messages": {
"welcome": "Welcome back, {{name}}! You have {{count}} new bookings."
}
}
// Inside component
<h1>{t('messages.welcome', { name: 'Firman', count: 5 })}</h1>
3. Usage Outside React Components (Vanilla TS)
For utility files, API interceptors, or vanilla functions where React hooks cannot be used, import the raw i18n instance directly.
import { i18n } from '@repo/core-i18n';
// Must specify the namespace explicitly if it's not 'common'
export const getErrorMessage = (code: string) => {
return i18n.t(`booking:errors.${code}`, { defaultValue: 'Unknown Error' });
};
4. 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.
// 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.
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 callchangeLanguage('en')without the callback function. It will update the UI and local storage instantly without hitting the network.
5. 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):
{
"data": {
"namespace": "booking",
"overrides": {
"module_name": "Procurement",
"header": {
"title": "Procurement List"
}
}
}
}