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:
Quinn Ftw 2025-12-28 17:41:57 -08:00
parent c2c9454b34
commit 04b70dfc0d
4 changed files with 377 additions and 72 deletions

View file

@ -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>
</>
);

View file

@ -121,6 +121,7 @@ export {
type MakeI18nConfig,
type I18nContextValue,
type TranslationData,
type BundledResources,
} from './makeI18n.js';
// SSR Support

View file

@ -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,

View file

@ -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;