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;