feat(i18n): add bundled resources support to makeI18n factory
Enhance makeI18n to support bundled resources as an alternative to API mode:
- Add BundledResources type for locale → namespace → translations structure
- Make apiUrl optional when resources are provided
- Add BundledResourcesProvider for instant loading (no React Query needed)
- Update useI18n() to accept optional namespace parameter
- Add mergeNamespaces() helper for combining multiple namespaces
- Export BundledResources type from package index
- Add makeI18nResources export to landing app locales
- Update MAKEI18N_README.md with bundled resources documentation
This enables single-provider architecture with dot notation access:
const i18n = useI18n('common')
return <h1>{i18n.navigation.home}</h1>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c2c9454b34
commit
04b70dfc0d
4 changed files with 377 additions and 72 deletions
|
|
@ -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 (
|
||||
<I18nProvider resources={resources}>
|
||||
<BrowserRouter>
|
||||
<Routes />
|
||||
</BrowserRouter>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<I18nProvider apiUrl="/api/i18n">
|
||||
<HomePage />
|
||||
<BrowserRouter>
|
||||
<Routes />
|
||||
</BrowserRouter>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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';
|
||||
|
||||
<I18nProvider
|
||||
resources={resources}
|
||||
config={{
|
||||
defaultLocale: 'en',
|
||||
defaultNamespace: 'common',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
```
|
||||
|
||||
**API Mode Example:**
|
||||
```tsx
|
||||
<I18nProvider
|
||||
apiUrl="/api/i18n"
|
||||
|
|
@ -236,10 +307,13 @@ interface I18nProviderProps {
|
|||
</I18nProvider>
|
||||
```
|
||||
|
||||
### `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 <h1>{i18n.hero.title}</h1>;
|
||||
}
|
||||
|
||||
// Using specific namespace
|
||||
function LandingPage() {
|
||||
const i18n = useI18n('landing');
|
||||
return <h1>{i18n.hero.title}</h1>;
|
||||
}
|
||||
|
||||
// Merging multiple namespaces
|
||||
function Dashboard() {
|
||||
const i18n = useI18n(['common', 'dashboard']);
|
||||
// Access keys from both namespaces
|
||||
return (
|
||||
<>
|
||||
<h1>{i18n.hero.title}</h1>
|
||||
<p>{i18n.hero.subtitle}</p>
|
||||
<h1>{i18n.dashboard.title}</h1>
|
||||
<button>{i18n.cta.primary}</button>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export {
|
|||
type MakeI18nConfig,
|
||||
type I18nContextValue,
|
||||
type TranslationData,
|
||||
type BundledResources,
|
||||
} from './makeI18n.js';
|
||||
|
||||
// SSR Support
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
* <I18nProvider apiUrl="/api/i18n">
|
||||
* <I18nProvider resources={resources}>
|
||||
* <Home />
|
||||
* </I18nProvider>
|
||||
* );
|
||||
|
|
@ -26,6 +26,19 @@
|
|||
* return <h1>{i18n.hero.title}</h1>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Usage with API (for ML/dynamic translations):
|
||||
* ```tsx
|
||||
* const { I18nProvider, useI18n } = makeI18n('landing', 'app');
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <I18nProvider apiUrl="/api/i18n">
|
||||
* <Home />
|
||||
* </I18nProvider>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
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<string, Record<string, TranslationData>>;
|
||||
|
||||
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 (
|
||||
* <I18nProvider resources={resources}>
|
||||
* <Home />
|
||||
* </I18nProvider>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // With API
|
||||
* function App() {
|
||||
* return (
|
||||
* <I18nProvider apiUrl="/api/i18n">
|
||||
* <Home />
|
||||
* </I18nProvider>
|
||||
|
|
@ -299,10 +363,84 @@ export function makeI18n(domain: string, app: string) {
|
|||
const I18nContext = createContext<I18nContextValue | null>(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<MakeI18nConfig, 'resources'>;
|
||||
}> = ({ 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<I18nContextValue>(
|
||||
() => ({
|
||||
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 <I18nContext.Provider value={contextValue}>{children}</I18nContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider for API mode (uses React Query)
|
||||
*/
|
||||
const ApiModeProvider: FC<{
|
||||
children: ReactNode;
|
||||
apiUrl: string;
|
||||
config: Omit<MakeI18nConfig, 'apiUrl'>;
|
||||
|
|
@ -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 <I18nContext.Provider value={contextValue}>{children}</I18nContext.Provider>;
|
||||
|
|
@ -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<Omit<MakeI18nConfig, 'apiUrl'>>;
|
||||
/** Bundled translation resources (recommended) */
|
||||
resources?: BundledResources;
|
||||
/** API URL for dynamic translations (optional if resources provided) */
|
||||
apiUrl?: string;
|
||||
/** Additional configuration */
|
||||
config?: Partial<Omit<MakeI18nConfig, 'apiUrl' | 'resources'>>;
|
||||
/** 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 (
|
||||
<BundledResourcesProvider resources={resources} config={fullConfig}>
|
||||
{children}
|
||||
</BundledResourcesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<QueryClientProvider client={internalQueryClient}>
|
||||
<I18nProviderInner apiUrl={apiUrl} config={fullConfig}>
|
||||
<ApiModeProvider apiUrl={apiUrl} config={fullConfig}>
|
||||
{children}
|
||||
</I18nProviderInner>
|
||||
</ApiModeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<I18nProviderInner apiUrl={apiUrl} config={fullConfig}>
|
||||
<ApiModeProvider apiUrl={apiUrl} config={fullConfig}>
|
||||
{children}
|
||||
</I18nProviderInner>
|
||||
</ApiModeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<TranslationData>(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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
*
|
||||
* <I18nProvider config={{ resources: bundledResources }}>
|
||||
* ```
|
||||
*
|
||||
* 2. makeI18n factory (dot notation access):
|
||||
* ```tsx
|
||||
* import { makeI18nResources } from './locales'
|
||||
* const { I18nProvider, useI18n } = makeI18n('landing', 'app')
|
||||
* <I18nProvider resources={makeI18nResources}>
|
||||
* // 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
|
||||
* <I18nProvider resources={makeI18nResources}>
|
||||
* <Routes />
|
||||
* </I18nProvider>
|
||||
*
|
||||
* // In components
|
||||
* const i18n = useI18n('common')
|
||||
* return <h1>{i18n.navigation.home}</h1>
|
||||
* ```
|
||||
*/
|
||||
export const makeI18nResources: BundledResources = bundledResources as BundledResources;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue