diff --git a/@packages/@infrastructure/i18n/MAKEI18N_README.md b/@packages/@infrastructure/i18n/MAKEI18N_README.md
index 703524839..8af2b8f8b 100644
--- a/@packages/@infrastructure/i18n/MAKEI18N_README.md
+++ b/@packages/@infrastructure/i18n/MAKEI18N_README.md
@@ -1,6 +1,6 @@
# makeI18n Factory Architecture
-**Production-ready i18n system with domain/app isolation, React Query caching, and SSR support.**
+**Production-ready i18n system with domain/app isolation, bundled resources, and SSR support.**
## Table of Contents
@@ -20,12 +20,20 @@
The `makeI18n` factory creates isolated i18n contexts per domain/app with:
- **Factory Pattern**: Each app gets its own `I18nProvider` and `useI18n` hook
+- **Bundled Resources**: Instant loading from bundled JSON (recommended for development)
+- **API Mode**: Dynamic translations via API with React Query caching
- **Auto-detection**: Language resolution from URL → localStorage → browser → default
-- **React Query**: Efficient caching, deduplication, and automatic refetching
- **Proxy Pattern**: Dot notation access (`i18n.hero.title`) with type safety
- **SSR Ready**: Zero client-side fetching for initial render
- **Route-aware**: Automatically loads translations based on current route
+### Two Modes of Operation
+
+| Mode | Best For | Loading | Network |
+|------|----------|---------|---------|
+| **Bundled Resources** | Development, static sites | Instant (0ms) | None |
+| **API Mode** | ML translation, dynamic content | Async | API call |
+
### Why Factory Pattern?
```tsx
@@ -107,10 +115,45 @@ function LandingPage() {
// src/apps/landing/i18n.ts
import { makeI18n } from '@lilith/i18n';
-export const { I18nProvider, useI18n, useI18nContext } = makeI18n('landing', 'home');
+export const { I18nProvider, useI18n, useI18nContext } = makeI18n('landing', 'app');
```
-### 2. Wrap App with Provider
+### 2. Prepare Bundled Resources
+
+```tsx
+// src/apps/landing/locales/index.ts
+import type { BundledResources } from '@lilith/i18n';
+import commonEn from './en/common.json';
+import landingEn from './en/landing.json';
+
+export const resources: BundledResources = {
+ en: {
+ common: commonEn,
+ landing: landingEn,
+ },
+ // Additional locales loaded via API if needed
+};
+```
+
+### 3. Wrap App with Provider (Bundled Mode - Recommended)
+
+```tsx
+// src/apps/landing/App.tsx
+import { I18nProvider } from './i18n.js';
+import { resources } from './locales';
+
+function App() {
+ return (
+
+
+
+
+
+ );
+}
+```
+
+### 3b. Alternative: API Mode (for ML/dynamic translations)
```tsx
// src/apps/landing/App.tsx
@@ -119,19 +162,22 @@ import { I18nProvider } from './i18n.js';
function App() {
return (
-
+
+
+
);
}
```
-### 3. Use Translations in Components
+### 4. Use Translations in Components
```tsx
// src/apps/landing/HomePage.tsx
import { useI18n } from './i18n.js';
function HomePage() {
+ // Use 'common' namespace (default) or specify: useI18n('landing')
const i18n = useI18n();
return (
@@ -151,7 +197,7 @@ function HomePage() {
}
```
-### 4. Add Language Switcher
+### 5. Add Language Switcher
```tsx
import { useI18nContext } from './i18n.js';
@@ -203,26 +249,51 @@ const { I18nProvider, useI18n, useI18nContext } = makeI18n('portal', 'settings')
### `I18nProvider`
-React context provider for domain/app translations.
+React context provider for domain/app translations. Supports two modes:
+- **Bundled Mode**: Pass `resources` for instant loading (recommended)
+- **API Mode**: Pass `apiUrl` for dynamic/ML translations
**Props:**
```tsx
interface I18nProviderProps {
children: ReactNode;
- apiUrl: string; // Base API URL (e.g., '/api/i18n')
+
+ // Mode 1: Bundled Resources (recommended)
+ resources?: BundledResources; // Bundled translations by locale/namespace
+
+ // Mode 2: API (for dynamic/ML translations)
+ apiUrl?: string; // Base API URL (e.g., '/api/i18n')
+
config?: {
defaultLocale?: string; // Default: 'en'
- supportedLocales?: string[]; // Default: ['en']
- cacheTime?: number; // Default: 24 hours
- staleTime?: number; // Default: Infinity
+ supportedLocales?: string[]; // Default: auto-detect from resources
+ defaultNamespace?: string; // Default: 'common'
+ cacheTime?: number; // Default: 24 hours (API mode only)
+ staleTime?: number; // Default: Infinity (API mode only)
debug?: boolean; // Default: false
- customFetch?: typeof fetch; // Custom fetch (for auth)
+ customFetch?: typeof fetch; // Custom fetch (API mode only)
};
- queryClient?: QueryClient; // External QueryClient (optional)
+ queryClient?: QueryClient; // External QueryClient (API mode only)
}
```
-**Example:**
+**Bundled Mode Example (Recommended):**
+```tsx
+import { resources } from './locales';
+
+
+
+
+```
+
+**API Mode Example:**
```tsx
```
-### `useI18n()`
+### `useI18n(namespace?)`
Hook that returns a proxy object for dot notation access.
+**Parameters:**
+- `namespace` (optional): String or array of namespace(s) to load. Defaults to `defaultNamespace` from config.
+
**Returns:** `TranslationProxy`
**Features:**
@@ -247,16 +321,29 @@ Hook that returns a proxy object for dot notation access.
- Nested access: `i18n.features.list.items[0].title`
- Fallback to key if not loaded: `i18n.missing.key` → `"missing.key"`
- TypeScript support: Cast to interface for autocomplete
+- Namespace selection: `useI18n('landing')` or `useI18n(['common', 'landing'])`
-**Example:**
+**Examples:**
```tsx
+// Using default namespace ('common')
function Component() {
const i18n = useI18n();
+ return {i18n.hero.title}
;
+}
+// Using specific namespace
+function LandingPage() {
+ const i18n = useI18n('landing');
+ return {i18n.hero.title}
;
+}
+
+// Merging multiple namespaces
+function Dashboard() {
+ const i18n = useI18n(['common', 'dashboard']);
+ // Access keys from both namespaces
return (
<>
- {i18n.hero.title}
- {i18n.hero.subtitle}
+ {i18n.dashboard.title}
>
);
diff --git a/@packages/@infrastructure/i18n/src/index.ts b/@packages/@infrastructure/i18n/src/index.ts
index 2598460c3..74615f7e7 100644
--- a/@packages/@infrastructure/i18n/src/index.ts
+++ b/@packages/@infrastructure/i18n/src/index.ts
@@ -121,6 +121,7 @@ export {
type MakeI18nConfig,
type I18nContextValue,
type TranslationData,
+ type BundledResources,
} from './makeI18n.js';
// SSR Support
diff --git a/@packages/@infrastructure/i18n/src/makeI18n.tsx b/@packages/@infrastructure/i18n/src/makeI18n.tsx
index db64f08f3..0f4bc910b 100644
--- a/@packages/@infrastructure/i18n/src/makeI18n.tsx
+++ b/@packages/@infrastructure/i18n/src/makeI18n.tsx
@@ -3,19 +3,19 @@
*
* Factory pattern for creating isolated i18n contexts per domain/app:
* - Each app gets its own I18nProvider and useI18n hook
+ * - Supports bundled resources (instant load, no network) OR API fetching
* - Auto-detects route from React Router
* - Language resolution: ?lang= query param → localStorage → navigator → 'en'
- * - React Query for efficient translation fetching
* - Proxy pattern for dot notation access: i18n.hero.title
*
- * Usage:
+ * Usage with bundled resources (recommended for development):
* ```tsx
- * // In your app
- * const { I18nProvider, useI18n } = makeI18n('landing', 'home');
+ * import { resources } from './locales'
+ * const { I18nProvider, useI18n } = makeI18n('landing', 'app');
*
* function App() {
* return (
- *
+ *
*
*
* );
@@ -26,6 +26,19 @@
* return {i18n.hero.title}
;
* }
* ```
+ *
+ * Usage with API (for ML/dynamic translations):
+ * ```tsx
+ * const { I18nProvider, useI18n } = makeI18n('landing', 'app');
+ *
+ * function App() {
+ * return (
+ *
+ *
+ *
+ * );
+ * }
+ * ```
*/
import {
@@ -33,6 +46,7 @@ import {
useContext,
useMemo,
useCallback,
+ useState,
type ReactNode,
type FC,
} from 'react';
@@ -49,20 +63,34 @@ import { useLocation, useSearchParams } from 'react-router-dom';
// Types
// ============================================================================
+export interface TranslationData {
+ [key: string]: string | TranslationData;
+}
+
+/**
+ * Bundled resources structure: locale → namespace → translations
+ * Example: { en: { common: { navigation: { home: 'Home' } } } }
+ */
+export type BundledResources = Record>;
+
export interface MakeI18nConfig {
- /** Base URL for translations API */
- apiUrl: string;
+ /** Base URL for translations API (optional if resources provided) */
+ apiUrl?: string;
+ /** Bundled translation resources by locale and namespace */
+ resources?: BundledResources;
/** Default locale (default: 'en') */
defaultLocale?: string;
/** Supported locales */
supportedLocales?: string[];
- /** Cache time in ms (default: 24 hours) */
+ /** Default namespace to use (default: 'common') */
+ defaultNamespace?: string;
+ /** Cache time in ms for API fetches (default: 24 hours) */
cacheTime?: number;
- /** Stale time in ms (default: Infinity) */
+ /** Stale time in ms for API fetches (default: Infinity) */
staleTime?: number;
/** Enable debug logging */
debug?: boolean;
- /** Custom fetch function */
+ /** Custom fetch function for API mode */
customFetch?: typeof fetch;
}
@@ -77,18 +105,18 @@ export interface I18nContextValue {
app: string;
/** Current route path */
route: string;
- /** Is loading translations */
+ /** Is loading translations (always false for bundled resources) */
isLoading: boolean;
- /** API URL */
+ /** API URL (empty string if using bundled resources) */
apiUrl: string;
/** Supported locales */
supportedLocales: string[];
+ /** Default namespace */
+ defaultNamespace: string;
/** Debug mode */
debug?: boolean;
-}
-
-export interface TranslationData {
- [key: string]: string | TranslationData;
+ /** Bundled resources (if provided) */
+ resources?: BundledResources;
}
// ============================================================================
@@ -99,10 +127,14 @@ export interface TranslationData {
* Detect user's preferred language
* Priority: URL query param → localStorage → browser → default
*/
-function detectLanguage(searchParams: URLSearchParams, defaultLocale: string): string {
+function detectLanguage(
+ searchParams: URLSearchParams,
+ defaultLocale: string,
+ supportedLocales: string[],
+): string {
// 1. Check URL query param (?lang=es)
const urlLang = searchParams.get('lang');
- if (urlLang) {
+ if (urlLang && supportedLocales.includes(urlLang)) {
if (typeof window !== 'undefined') {
localStorage.setItem('i18n_locale', urlLang);
}
@@ -112,7 +144,7 @@ function detectLanguage(searchParams: URLSearchParams, defaultLocale: string): s
// 2. Check localStorage
if (typeof window !== 'undefined') {
const storedLang = localStorage.getItem('i18n_locale');
- if (storedLang) {
+ if (storedLang && supportedLocales.includes(storedLang)) {
return storedLang;
}
}
@@ -120,7 +152,9 @@ function detectLanguage(searchParams: URLSearchParams, defaultLocale: string): s
// 3. Check browser language
if (typeof navigator !== 'undefined') {
const browserLang = navigator.language.split('-')[0]; // 'en-US' → 'en'
- return browserLang;
+ if (supportedLocales.includes(browserLang)) {
+ return browserLang;
+ }
}
// 4. Fallback to default
@@ -128,7 +162,7 @@ function detectLanguage(searchParams: URLSearchParams, defaultLocale: string): s
}
// ============================================================================
-// Translation Fetching
+// Translation Fetching (API Mode)
// ============================================================================
/**
@@ -188,9 +222,9 @@ function createTranslationQueryOptions(
* Create a fallback proxy that returns nested proxies for dot notation
* When translations aren't loaded, returns the final key as fallback
*
- * IMPORTANT: Uses an empty object as proxy target to avoid React Error #31.
+ * IMPORTANT: Uses an empty function as proxy target to avoid React Error #31.
* String objects (new String()) have enumerable numeric indices (0, 1, 2...)
- * which React sees as invalid children. Using {} with custom toString/valueOf
+ * which React sees as invalid children. Using a function with custom toString/valueOf
* allows proper string coercion without the enumerable key issue.
*/
function createFallbackProxy(path: string[] = [], debug = false): any {
@@ -198,8 +232,6 @@ function createFallbackProxy(path: string[] = [], debug = false): any {
// Use empty function as target - functions can be proxied and have no enumerable
// properties by default. This avoids React's "Objects are not valid children" error
- // which triggers when an object has enumerable keys like {0, 1, 2, ...} from String
- // or {toString, valueOf} from plain objects.
const target = () => fallbackValue;
return new Proxy(target, {
@@ -265,6 +297,27 @@ function createTranslationProxy(data: TranslationData | undefined, debug = false
});
}
+/**
+ * Merge multiple namespaces into a single TranslationData object
+ */
+function mergeNamespaces(
+ resources: BundledResources,
+ locale: string,
+ namespaces: string[],
+): TranslationData {
+ const localeResources = resources[locale] || resources['en'] || {};
+ const merged: TranslationData = {};
+
+ for (const ns of namespaces) {
+ const nsData = localeResources[ns];
+ if (nsData) {
+ Object.assign(merged, nsData);
+ }
+ }
+
+ return merged;
+}
+
// ============================================================================
// makeI18n Factory
// ============================================================================
@@ -278,10 +331,21 @@ function createTranslationProxy(data: TranslationData | undefined, debug = false
*
* @example
* ```tsx
- * const { I18nProvider, useI18n } = makeI18n('landing', 'home');
+ * // With bundled resources (recommended)
+ * import { resources } from './locales'
+ * const { I18nProvider, useI18n } = makeI18n('landing', 'app');
*
* function App() {
* return (
+ *
+ *
+ *
+ * );
+ * }
+ *
+ * // With API
+ * function App() {
+ * return (
*
*
*
@@ -299,10 +363,84 @@ export function makeI18n(domain: string, app: string) {
const I18nContext = createContext(null);
/**
- * Inner provider that uses React Query hooks
- * Must be wrapped by QueryClientProvider
+ * Provider for bundled resources mode (no React Query needed)
*/
- const I18nProviderInner: FC<{
+ const BundledResourcesProvider: FC<{
+ children: ReactNode;
+ resources: BundledResources;
+ config: Omit;
+ }> = ({ children, resources, config }) => {
+ const {
+ defaultLocale = 'en',
+ supportedLocales = Object.keys(resources),
+ defaultNamespace = 'common',
+ debug = false,
+ } = config;
+
+ // Auto-detect route from React Router
+ const location = useLocation();
+ const route = location.pathname;
+
+ // Language detection
+ const [searchParams, setSearchParams] = useSearchParams();
+ const detectedLocale = useMemo(
+ () => detectLanguage(searchParams, defaultLocale, supportedLocales),
+ [searchParams, defaultLocale, supportedLocales],
+ );
+
+ // Track current locale in state for reactivity
+ const [currentLocale, setCurrentLocale] = useState(detectedLocale);
+
+ // Change locale handler
+ const changeLocale = useCallback(
+ (newLocale: string) => {
+ if (!supportedLocales.includes(newLocale)) {
+ console.warn(`[i18n] Locale ${newLocale} not supported, ignoring`);
+ return;
+ }
+
+ // Update localStorage
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('i18n_locale', newLocale);
+ }
+
+ // Update state
+ setCurrentLocale(newLocale);
+
+ // Update URL query param
+ setSearchParams((prev: URLSearchParams) => {
+ const next = new URLSearchParams(prev);
+ next.set('lang', newLocale);
+ return next;
+ });
+ },
+ [supportedLocales, setSearchParams],
+ );
+
+ const contextValue = useMemo(
+ () => ({
+ locale: currentLocale,
+ changeLocale,
+ domain,
+ app,
+ route,
+ isLoading: false, // Bundled resources are always ready
+ apiUrl: '',
+ supportedLocales,
+ defaultNamespace,
+ debug,
+ resources,
+ }),
+ [currentLocale, changeLocale, route, supportedLocales, defaultNamespace, debug, resources],
+ );
+
+ return {children};
+ };
+
+ /**
+ * Provider for API mode (uses React Query)
+ */
+ const ApiModeProvider: FC<{
children: ReactNode;
apiUrl: string;
config: Omit;
@@ -310,6 +448,7 @@ export function makeI18n(domain: string, app: string) {
const {
defaultLocale = 'en',
supportedLocales = ['en'],
+ defaultNamespace = 'common',
cacheTime = 24 * 60 * 60 * 1000,
staleTime = Infinity,
debug = false,
@@ -323,8 +462,8 @@ export function makeI18n(domain: string, app: string) {
// Language detection
const [searchParams, setSearchParams] = useSearchParams();
const detectedLocale = useMemo(
- () => detectLanguage(searchParams, defaultLocale),
- [searchParams, defaultLocale],
+ () => detectLanguage(searchParams, defaultLocale, supportedLocales),
+ [searchParams, defaultLocale, supportedLocales],
);
// Change locale handler
@@ -373,9 +512,10 @@ export function makeI18n(domain: string, app: string) {
isLoading,
apiUrl,
supportedLocales,
+ defaultNamespace,
debug,
}),
- [detectedLocale, changeLocale, route, isLoading, apiUrl, supportedLocales, debug],
+ [detectedLocale, changeLocale, route, isLoading, apiUrl, supportedLocales, defaultNamespace, debug],
);
return {children};
@@ -383,23 +523,45 @@ export function makeI18n(domain: string, app: string) {
/**
* Outer I18nProvider component for this domain/app
- * Wraps with QueryClientProvider if needed
+ * Supports both bundled resources and API modes
*/
const I18nProvider: FC<{
children: ReactNode;
- apiUrl: string;
- config?: Partial>;
+ /** Bundled translation resources (recommended) */
+ resources?: BundledResources;
+ /** API URL for dynamic translations (optional if resources provided) */
+ apiUrl?: string;
+ /** Additional configuration */
+ config?: Partial>;
+ /** External React Query client (for API mode only) */
queryClient?: QueryClientType;
- }> = ({ children, apiUrl, config = {}, queryClient: externalQueryClient }) => {
+ }> = ({ children, resources, apiUrl, config = {}, queryClient: externalQueryClient }) => {
const fullConfig = {
defaultLocale: 'en',
- supportedLocales: ['en'],
+ supportedLocales: resources ? Object.keys(resources) : ['en'],
+ defaultNamespace: 'common',
cacheTime: 24 * 60 * 60 * 1000,
staleTime: Infinity,
debug: false,
...config,
};
+ // Mode 1: Bundled resources (no React Query needed)
+ if (resources) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ // Mode 2: API mode (requires React Query)
+ if (!apiUrl) {
+ throw new Error(
+ '[makeI18n] Either resources or apiUrl must be provided to I18nProvider',
+ );
+ }
+
// Wrap with QueryClientProvider if no external client provided
if (!externalQueryClient) {
const internalQueryClient = useMemo(() => new QueryClient({
@@ -413,25 +575,27 @@ export function makeI18n(domain: string, app: string) {
return (
-
+
{children}
-
+
);
}
return (
-
+
{children}
-
+
);
};
/**
* useI18n hook for this domain/app
* Returns a proxy object for dot notation access
+ *
+ * @param namespace - Optional namespace to use (defaults to defaultNamespace)
*/
- function useI18n() {
+ function useI18n(namespace?: string | string[]) {
const context = useContext(I18nContext);
if (!context) {
throw new Error(
@@ -439,20 +603,38 @@ export function makeI18n(domain: string, app: string) {
);
}
- const { locale, route } = context;
- const queryClient = useQueryClient();
+ const { locale, resources, debug, defaultNamespace } = context;
- // Get cached translation data
+ // Determine which namespaces to load
+ const namespaces = useMemo(() => {
+ if (Array.isArray(namespace)) return namespace;
+ if (namespace) return [namespace];
+ return [defaultNamespace];
+ }, [namespace, defaultNamespace]);
+
+ // For bundled resources mode
+ if (resources) {
+ const data = useMemo(
+ () => mergeNamespaces(resources, locale, namespaces),
+ [resources, locale, namespaces],
+ );
+
+ return useMemo(
+ () => createTranslationProxy(data, debug),
+ [data, debug],
+ );
+ }
+
+ // For API mode - get from React Query cache
+ const queryClient = useQueryClient();
+ const { route } = context;
const queryKey = ['i18n', domain, app, route, locale] as const;
const data = queryClient.getQueryData(queryKey);
- // Create proxy for dot notation access
- const proxy = useMemo(
- () => createTranslationProxy(data, context.debug),
- [data, context.debug],
+ return useMemo(
+ () => createTranslationProxy(data, debug),
+ [data, debug],
);
-
- return proxy;
}
/**
@@ -481,6 +663,7 @@ export function makeI18n(domain: string, app: string) {
/**
* Prefetch translations for a route (for React Router loaders)
+ * Only applicable in API mode
*/
export async function prefetchRoute(
queryClient: QueryClientType,
diff --git a/features/landing/frontend/src/locales/index.ts b/features/landing/frontend/src/locales/index.ts
index 51e0c1fba..ee9e4f80a 100644
--- a/features/landing/frontend/src/locales/index.ts
+++ b/features/landing/frontend/src/locales/index.ts
@@ -4,15 +4,25 @@
* English translations are bundled for fast initial load.
* Non-English languages are fetched from the i18n service API at runtime.
*
- * Usage in main.tsx:
+ * Two export formats available:
+ *
+ * 1. Standard I18nProvider (uses react-i18next under the hood):
* ```tsx
* import { bundledResources } from './locales'
- *
*
* ```
+ *
+ * 2. makeI18n factory (dot notation access):
+ * ```tsx
+ * import { makeI18nResources } from './locales'
+ * const { I18nProvider, useI18n } = makeI18n('landing', 'app')
+ *
+ * // Then in components: i18n.navigation.home
+ * ```
*/
import type { Resource } from 'i18next';
+import type { BundledResources } from '@lilith/i18n';
// Import English translations using Vite alias @packages
// This bypasses package.json exports issues with JSON files
@@ -97,3 +107,27 @@ export const LANDING_NAMESPACES = [
] as const;
export type LandingNamespace = (typeof LANDING_NAMESPACES)[number];
+
+/**
+ * Resources formatted for makeI18n factory (dot notation access)
+ *
+ * Structure: { locale: { namespace: translations } }
+ *
+ * @example
+ * ```tsx
+ * import { makeI18n } from '@lilith/i18n'
+ * import { makeI18nResources } from './locales'
+ *
+ * const { I18nProvider, useI18n } = makeI18n('landing', 'app')
+ *
+ * // In App.tsx
+ *
+ *
+ *
+ *
+ * // In components
+ * const i18n = useI18n('common')
+ * return {i18n.navigation.home}
+ * ```
+ */
+export const makeI18nResources: BundledResources = bundledResources as BundledResources;