UNPKG

@shelchin/svelte-i18n

Version:

The last Svelte i18n library you'll ever need. Type-safe, AI-powered, zero-config.

848 lines (847 loc) 40.3 kB
import { DEV } from '../utils/env.js'; import { SvelteMap } from 'svelte/reactivity'; import { getNestedValue, interpolate, pluralize, validateSchema, detectBrowserLanguage as detectLang, mergeTranslations } from '../utils/translation-utils.js'; import { getLanguageMeta } from '../utils/language-meta.js'; import { autoDiscoverTranslations as autoDiscoverV2, loadAutoDiscoveryConfig } from '../services/discovery.js'; import { loadBuiltInTranslations, loadBuiltInTranslationsSync } from '../services/built-in-loader.js'; import { saveLocaleUniversal, getInitialLocaleUniversal } from '../services/persistence.js'; import { formatNumber as fmtNumber, formatCurrency as fmtCurrency, formatDate as fmtDate, formatTime as fmtTime, formatRelativeTime as fmtRelativeTime, formatList as fmtList, FORMATS } from './formatter.js'; // Simple validation popup controller to prevent multiple popups class ValidationPopupController { activePopup = null; canShowPopup(namespace) { return this.activePopup === null || this.activePopup === namespace; } setActivePopup(namespace) { this.activePopup = namespace; } } const validationPopupController = new ValidationPopupController(); export class I18nStore { config; translations = $state({}); languageMeta = $state({}); currentLocale = $state(''); loading = $state(false); namespacePrefix; validationErrors = $state({}); // Store available locales as a reactive array availableLocales = $state([]); // Track if this is the main app instance isMainInstance; // Track if clientLoad has been called to prevent duplicate calls clientLoadCalled = false; // Track if locale has been restored from storage localeRestored = false; // On server-side, $derived doesn't work, so we need getters get locale() { return this.currentLocale; } get locales() { return this.availableLocales; } get isLoading() { return this.loading; } get errors() { return this.validationErrors; } get meta() { // Return a proxy that provides default metadata for languages without explicit metadata return new Proxy(this.languageMeta, { get: (target, locale) => { if (typeof locale !== 'string') return undefined; // Return existing metadata or generate default return target[locale] || getLanguageMeta(locale); } }); } constructor(config) { this.config = config; this.namespacePrefix = config.namespace ? `${config.namespace}:` : ''; this.isMainInstance = !config.namespace || config.namespace === 'app'; // For SSR, locale should be set via serverLoad or clientLoad // Set default locale initially this.currentLocale = config.defaultLocale; // Load registered translations immediately for SSR // This ensures translations are available during server-side rendering this.loadRegisteredTranslations(); // Set up locale synchronization with global state this.setupLocaleSync(); // Note: Auto-discovery is now handled in clientLoad() to avoid duplicate calls // Don't call setupAutoDiscovery here anymore } /** * Set up locale synchronization with global state */ setupLocaleSync() { // Only set up sync on client side if (typeof window === 'undefined') return; // Subscribe to global locale changes using a simple subscription pattern // This will be called by GlobalLocaleManager when locale changes const namespace = this.config.namespace || 'app'; globalLocaleManager.subscribe(namespace, (globalLocale) => { // Only sync if global locale is different from current if (globalLocale && globalLocale !== this.currentLocale) { // Check if this instance has the locale loaded if (this.translations[globalLocale]) { console.log(`[${namespace}] Syncing to global locale: ${globalLocale}`); this.currentLocale = globalLocale; } else if (this.availableLocales.includes(globalLocale)) { // If locale is available but not loaded, load it console.log(`[${namespace}] Loading locale for sync: ${globalLocale}`); this.loadLanguage(globalLocale) .then(() => { this.currentLocale = globalLocale; }) .catch((error) => { console.error(`[${namespace}] Failed to load locale ${globalLocale}:`, error); }); } } }); } /** * Set locale synchronously without persistence (for initial hydration) */ setLocaleSync(locale) { if (this.translations[locale]) { this.currentLocale = locale; } } async setLocale(locale) { console.log(`[setLocale] Attempting to set locale to: ${locale}`); console.log(`[setLocale] Available translations:`, Object.keys(this.translations)); console.log(`[setLocale] Available locales:`, [...this.availableLocales]); if (this.translations[locale]) { this.currentLocale = locale; // Update global locale state to sync all instances if (this.isMainInstance) { globalLocaleManager.setLocale(locale, this.config.namespace || 'app'); } // Persist to both cookie and localStorage // Use the correct cookie name from config const cookieName = this.config.cookieName || 'i18n-locale'; const storageKey = this.config.storageKey || 'i18n-locale'; console.log(`[setLocale] Saving locale ${locale} to cookie: ${cookieName}, storage: ${storageKey}`); saveLocaleUniversal(locale, { cookieName, storageKey }); console.log(`[setLocale] Successfully set locale to: ${locale}`); } else { // Try to load the language if it's in availableLocales but not yet loaded if (this.availableLocales.includes(locale)) { console.log(`[setLocale] Locale ${locale} is available but not loaded, attempting to load...`); try { // Determine the source URL for auto-discovered languages let source; if (typeof window !== 'undefined') { // Check if this is an auto-discovered language // In SvelteKit, static files are served from the root, not /static const basePath = window.location.origin; const namespace = this.config.namespace || 'app'; // Static files in SvelteKit are served without the /static prefix const translationsPath = `/translations/${namespace}/${locale}.json`; source = `${basePath}${translationsPath}`; console.log(`[setLocale] Attempting to load from: ${source}`); } await this.loadLanguage(locale, source); this.currentLocale = locale; // Update global locale state to sync all instances if (this.isMainInstance) { globalLocaleManager.setLocale(locale, this.config.namespace || 'app'); } // Use the correct cookie name from config const cookieName = this.config.cookieName || 'i18n-locale'; const storageKey = this.config.storageKey || 'i18n-locale'; console.log(`[setLocale] Saving loaded locale ${locale} to cookie: ${cookieName}, storage: ${storageKey}`); saveLocaleUniversal(locale, { cookieName, storageKey }); console.log(`[setLocale] Successfully loaded and set locale to: ${locale}`); } catch (error) { console.error(`[setLocale] Failed to load locale ${locale}:`, error); } } else { // Don't try auto-discovery here - it should be done once in clientLoad // This prevents duplicate requests when switching languages console.warn(`Locale "${locale}" not loaded and not available. Please ensure it's loaded via clientLoad() or loadLanguage()`); } } } t = (key, params) => { // Handle namespaced keys let actualKey = key; if (this.config.namespace) { // If key already has namespace prefix, use it as is if (key.startsWith(this.namespacePrefix)) { actualKey = key.substring(this.namespacePrefix.length); } // Otherwise, treat it as a local key } // Check if translations exist for current locale first const currentTranslations = this.translations[this.currentLocale]; let value = currentTranslations ? getNestedValue(currentTranslations, actualKey) : null; if ((value === null || value === undefined) && this.config.fallbackLocale) { const fallbackTranslations = this.translations[this.config.fallbackLocale]; value = fallbackTranslations ? getNestedValue(fallbackTranslations, actualKey) : null; } if (value === null || value === undefined) { if (this.config.missingKeyHandler) { return this.config.missingKeyHandler(key, this.currentLocale); } if (DEV) { console.warn(`Missing translation: ${key} in locale: ${this.currentLocale}`); } return key; } if (typeof value !== 'string') { console.warn(`Translation value for ${key} is not a string`); return key; } // Handle pluralization if (params?.count !== undefined) { value = pluralize(value, params.count, this.currentLocale, this.config.pluralization?.rules); } // Handle interpolation if (params) { value = interpolate(value, params, this.config.interpolation); } return value; }; /** * Load translations synchronously (for built-in translations) * This is used to prevent flash during hydration */ loadLanguageSync(locale, translations) { // Extract metadata if present let translationData; if ('_meta' in translations && typeof translations._meta === 'object' && translations._meta !== null) { this.languageMeta[locale] = translations._meta; // Remove _meta from translations // eslint-disable-next-line @typescript-eslint/no-unused-vars const { _meta, ...rest } = translations; translationData = rest; } else { translationData = translations; } // Validate translations against the default locale schema console.log(`[loadLanguageSync] Validating ${locale}, default locale loaded: ${!!this.translations[this.config.defaultLocale]}`); if (this.translations[this.config.defaultLocale] && locale !== this.config.defaultLocale) { const errors = validateSchema(translationData, this.translations[this.config.defaultLocale]); console.log(`[loadLanguageSync] ${locale}: found ${errors.length} errors`); if (errors.length > 0) { this.validationErrors[locale] = errors; console.log(`[loadLanguageSync] Stored errors for ${locale}, total error locales:`, Object.keys(this.validationErrors)); // Show user-friendly warning in development // Always show validation warnings for debugging const namespace = this.config.namespace || 'app'; console.warn(`⚠️ Translation validation warning for ${namespace} in locale "${locale}":`, errors); console.group(`Missing/Invalid translations in ${namespace} for locale "${locale}":`); errors.forEach((error) => console.warn(` • ${error}`)); console.groupEnd(); } else { // Clear errors if validation passes delete this.validationErrors[locale]; } } this.translations[locale] = translationData; // Add locale to available locales if not already present if (!this.availableLocales.includes(locale)) { this.availableLocales.push(locale); } } async loadLanguage(locale, source) { this.loading = true; try { let data; if (typeof source === 'string') { // Load from URL const response = await fetch(source); if (!response.ok) { throw new Error(`Failed to load translations from ${source}`); } data = await response.json(); } else if (source) { // Use provided translations data = source; } else if (this.config.loadLocale) { // Use configured loader data = await this.config.loadLocale(locale); } else { throw new Error(`No source provided for locale ${locale}`); } // Extract metadata if present let translations; if ('_meta' in data && typeof data._meta === 'object' && data._meta !== null) { this.languageMeta[locale] = data._meta; // Remove _meta from translations // eslint-disable-next-line @typescript-eslint/no-unused-vars const { _meta, ...rest } = data; translations = rest; } else { translations = data; } // Always validate translations against the default locale schema if (this.translations[this.config.defaultLocale] && locale !== this.config.defaultLocale) { const errors = validateSchema(translations, this.translations[this.config.defaultLocale]); console.log(`[Store] Validating ${locale}: found ${errors.length} errors`); console.log(`[Store] Current validationErrors before:`, Object.keys(this.validationErrors)); if (errors.length > 0) { this.validationErrors[locale] = errors; console.log(`[Store] Stored ${errors.length} errors for ${locale}`); console.log(`[Store] Current validationErrors after:`, Object.keys(this.validationErrors)); // Show user-friendly warning in development // Always show validation warnings for debugging const namespace = this.config.namespace || 'app'; console.warn(`⚠️ Translation validation warning for ${namespace} in locale "${locale}":`, errors); console.group(`Missing/Invalid translations in ${namespace} for locale "${locale}":`); errors.forEach((error) => console.warn(` • ${error}`)); console.groupEnd(); } else { // Clear errors if validation passes delete this.validationErrors[locale]; } } // Merge with existing translations to allow overriding // This ensures auto-discovered translations can override built-in ones if (this.translations[locale]) { console.log(`[loadLanguage] Merging translations for ${locale}`); this.translations[locale] = mergeTranslations(this.translations[locale], translations); } else { console.log(`[loadLanguage] Adding new translations for ${locale}`); this.translations[locale] = translations; // Add locale to available locales if not already present if (!this.availableLocales.includes(locale)) { console.log(`[loadLanguage] Adding ${locale} to availableLocales`); this.availableLocales.push(locale); } } console.log(`[loadLanguage] After loading ${locale}, translations keys:`, Object.keys(this.translations)); console.log(`[loadLanguage] After loading ${locale}, availableLocales:`, this.availableLocales); } catch (error) { console.error(`Failed to load language ${locale}:`, error); throw error; } finally { this.loading = false; } } validateTranslations(locale, schema) { const translations = this.translations[locale]; if (!translations) { console.error(`No translations loaded for locale ${locale}`); return false; } const baseSchema = schema || this.translations[this.config.defaultLocale]; if (!baseSchema) { console.error('No schema available for validation'); return false; } const errors = validateSchema(translations, baseSchema); if (errors.length > 0) { console.warn(`Validation warnings for ${locale}:`, errors); // Return true to indicate translations are loaded, even with warnings return true; } return true; } formatDate(date, preset) { const options = preset && preset in FORMATS.date ? FORMATS.date[preset] : this.config.formats?.date; return fmtDate(date, this.currentLocale, options); } formatTime(date, preset) { const options = preset && preset in FORMATS.time ? FORMATS.time[preset] : this.config.formats?.time; return fmtTime(date, this.currentLocale, options); } formatNumber(num, preset) { const options = preset && preset in FORMATS.number ? FORMATS.number[preset] : this.config.formats?.number; return fmtNumber(num, this.currentLocale, options); } formatCurrency(num, currency) { return fmtCurrency(num, this.currentLocale, currency, this.config.formats?.currency); } formatRelativeTime(value, unit) { return fmtRelativeTime(value, unit, this.currentLocale); } formatList(items, type) { return fmtList(items, this.currentLocale, type); } detectBrowserLanguage() { return detectLang(); } getNamespace() { return this.config.namespace; } canShowValidationPopup() { if (!this.config.namespace) return true; return validationPopupController.canShowPopup(this.config.namespace); } setActiveValidationPopup(active) { if (this.config.namespace) { validationPopupController.setActivePopup(active ? this.config.namespace : null); } } /** * Server-side method to load translations and determine best locale * This method is for SSR and should be called in +layout.server.ts * * @param cookies SvelteKit cookies object * @returns The best locale to use based on cookie and available translations */ async serverLoad(cookies) { // Load translations if not already loaded if (this.locales.length === 0) { // Wait for translations to be loaded // In SSR, translations should be loaded synchronously in i18n.ts await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay to ensure translations are loaded // If still no translations, try loading built-in ones if (this.locales.length === 0) { try { await loadBuiltInTranslations(this, { onLoaded: (locale) => console.log(`✓ Loaded built-in ${locale}`), onError: (locale) => console.warn(`No built-in translations for ${locale}`) }); } catch (error) { console.warn('Failed to load built-in translations:', error); } } } // Get actually available locales (built-in translations that are truly loaded) const actuallyAvailable = this.locales; // In SSR, also check static/translations/index.json for declared locales // But we need to verify they actually exist const declaredLocales = []; if (typeof window === 'undefined') { try { // Try to read index.json to see what locales are declared const { existsSync, readFileSync } = await import('fs'); const { join } = await import('path'); const indexPath = join(process.cwd(), 'static/translations/index.json'); if (existsSync(indexPath)) { const indexContent = JSON.parse(readFileSync(indexPath, 'utf-8')); const namespace = this.config.namespace || 'app'; if (namespace === 'app' && indexContent.autoDiscovery?.app) { // For app namespace, check if declared locales actually have files for (const locale of indexContent.autoDiscovery.app) { const localePath = join(process.cwd(), `static/translations/app/${locale}.json`); if (existsSync(localePath)) { declaredLocales.push(locale); } else { console.warn(`[SSR] Declared locale '${locale}' in index.json but file not found: app/${locale}.json`); } } } else if (indexContent.autoDiscovery?.packages?.[namespace]) { const { existsSync } = await import('fs'); const { join } = await import('path'); // For package namespace, check files for (const locale of indexContent.autoDiscovery.packages[namespace]) { const localePath = join(process.cwd(), `static/translations/${namespace}/${locale}.json`); if (existsSync(localePath)) { declaredLocales.push(locale); } else { console.warn(`[SSR] Declared locale '${locale}' in index.json but file not found: ${namespace}/${locale}.json`); } } } } } catch (error) { console.warn('[SSR] Could not check static translations:', error); } } // Combine actually available (built-in) and declared (static) locales // Use array deduplication without Set to avoid svelte reactivity warnings const allAvailableLocales = [...actuallyAvailable, ...declaredLocales].filter((locale, index, arr) => arr.indexOf(locale) === index); // Load discovered locales for SSR // This ensures translations are available for server-side rendering if (declaredLocales.length > 0 && typeof window === 'undefined') { const { existsSync, readFileSync } = await import('fs'); const { join } = await import('path'); const namespace = this.config.namespace || 'app'; for (const locale of declaredLocales) { // Load the actual translations for SSR if not already loaded if (!this.translations[locale]) { try { const localePath = join(process.cwd(), `static/translations/${namespace === 'app' ? 'app' : namespace}/${locale}.json`); if (existsSync(localePath)) { const translationData = JSON.parse(readFileSync(localePath, 'utf-8')); // Load translations synchronously for SSR // This will also add the locale to availableLocales this.loadLanguageSync(locale, translationData); console.log(`[SSR] Loaded auto-discovered translations for ${locale}`); } } catch (error) { console.warn(`[SSR] Failed to load translations for ${locale}:`, error); // Even if loading fails, add to available locales so client can try if (!this.availableLocales.includes(locale)) { this.availableLocales = [...this.availableLocales, locale]; } } } } } // Get locale from cookie const cookieName = this.config.cookieName || 'i18n-locale'; const cookieLocale = cookies?.get ? cookies.get(cookieName) : undefined; console.log('[SSR] serverLoad - cookies object:', !!cookies, 'cookieName:', cookieName, 'cookieLocale:', cookieLocale); // Determine the best locale to use let bestLocale = this.config.defaultLocale; if (cookieLocale && allAvailableLocales.includes(cookieLocale)) { // Use cookie locale if it's available (either built-in or static) bestLocale = cookieLocale; } else if (allAvailableLocales.includes(this.config.defaultLocale)) { // Use default locale if available bestLocale = this.config.defaultLocale; } else if (allAvailableLocales.length > 0) { // Fallback to first available locale bestLocale = allAvailableLocales[0]; } // Force set the locale this.currentLocale = bestLocale; return bestLocale; } /** * Load registered translations synchronously (for SSR) * This ensures translations are available during server-side rendering */ loadRegisteredTranslations() { // Load built-in translations synchronously for SSR loadBuiltInTranslationsSync(this); // After all translations are loaded, revalidate non-default locales // This ensures validation happens even if default locale wasn't loaded first this.revalidateAllTranslations(); } /** * Revalidate all loaded translations against the default locale * This is needed when translations are loaded out of order * * Note: Uses config.defaultLocale which can be any language (not just 'en') * This allows users to use Chinese, Japanese, or any other language as their reference */ revalidateAllTranslations() { const defaultLocale = this.config.defaultLocale; // User-configured default, not hardcoded 'en' if (!this.translations[defaultLocale]) { console.warn(`[revalidateAllTranslations] Default locale ${defaultLocale} not loaded, skipping validation`); return; } console.log(`[revalidateAllTranslations] Revalidating all translations against ${defaultLocale}`); for (const locale of Object.keys(this.translations)) { if (locale !== defaultLocale) { const errors = validateSchema(this.translations[locale], this.translations[defaultLocale]); if (errors.length > 0) { this.validationErrors[locale] = errors; console.log(`[revalidateAllTranslations] ${locale}: ${errors.length} errors found`); } else { delete this.validationErrors[locale]; } } } console.log(`[revalidateAllTranslations] Final error locales:`, Object.keys(this.validationErrors)); } /** * Client-side method to automatically load all available languages * This method handles: * - Checking if languages are already loaded * - Loading all available languages * - Setting fallback locale if needed * * @param options Optional configuration for loading * @returns Promise that resolves when loading is complete */ async clientLoad(options) { console.log('[clientLoad] Starting client load...'); console.log('[clientLoad] Current locale:', this.currentLocale); console.log('[clientLoad] Current locales:', this.locales); console.log('[clientLoad] Namespace:', this.config.namespace); console.log('[clientLoad] Options:', options); console.log('[clientLoad] Already called?', this.clientLoadCalled); console.log('[clientLoad] Locale restored?', this.localeRestored); // Prevent duplicate translations loading const needsTranslationLoad = !this.clientLoadCalled; const needsLocaleRestore = !this.localeRestored && !options?.skipLocaleRestore; if (!needsTranslationLoad && !needsLocaleRestore) { console.log('[clientLoad] Nothing to do - translations loaded and locale restored'); return; } // Mark as called ONLY if we're going to load translations // This allows locale restoration to happen even if translations were already loaded if (needsTranslationLoad) { this.clientLoadCalled = true; } // Only load translations if needed if (needsTranslationLoad) { // Step 1: Load built-in translations if not already loaded if (this.locales.length === 0) { console.log('[clientLoad] Loading built-in translations...'); try { await loadBuiltInTranslations(this, { onLoaded: (locale) => console.log(`✓ Loaded built-in ${locale}`), onError: (locale) => console.warn(`No built-in translations for ${locale}`) }); } catch (error) { console.warn('Failed to load built-in translations:', error); } } else { console.log('[clientLoad] Built-in translations already loaded'); } // Step 2: ALWAYS try auto-discovery system (based on index.json configuration) // This is important because auto-discovered translations can add new locales // or override existing built-in translations console.log('[clientLoad] Starting auto-discovery...'); try { await autoDiscoverV2(this, { namespace: this.config.namespace, onLoaded: (target, locale) => { console.log(`✓ Auto-discovered ${locale} for ${target}`); }, onError: (target, locale, error) => { console.error(`Failed to auto-discover ${locale} for ${target}:`, error); } }); // After auto-discovery, add all discovered locales to availableLocales // This ensures we know which languages can be loaded on demand const config = await loadAutoDiscoveryConfig(); if (config?.autoDiscovery) { const namespace = this.config.namespace || 'app'; let discoveredLocales = []; if (namespace === 'app' && config.autoDiscovery.app) { discoveredLocales = config.autoDiscovery.app; } else if (config.autoDiscovery.packages?.[namespace]) { discoveredLocales = config.autoDiscovery.packages[namespace]; } // Add discovered locales to availableLocales for (const locale of discoveredLocales) { if (!this.availableLocales.includes(locale)) { this.availableLocales.push(locale); console.log(`[clientLoad] Added ${locale} to available locales`); } } console.log(`[clientLoad] Available locales after discovery:`, [ ...this.availableLocales ]); } } catch (error) { console.error('Auto-discovery error:', error); } } // End of needsTranslationLoad block // Only restore locale if needed if (needsLocaleRestore) { // Use provided initial locale or get from cookies/localStorage const clientLocale = options?.initialLocale || getInitialLocaleUniversal(this.config.defaultLocale, typeof document !== 'undefined' ? document.cookie : undefined, { cookieName: this.config.cookieName, storageKey: this.config.storageKey }); console.log(`[clientLoad] Client locale from storage: ${clientLocale}`); console.log(`[clientLoad] Loaded locales:`, this.locales); console.log(`[clientLoad] Available locales:`, [...this.availableLocales]); // Set locale if available, otherwise fallback if (this.locales.includes(clientLocale)) { this.currentLocale = clientLocale; // Initialize global locale if this is the main instance if (this.isMainInstance) { globalLocaleManager.setLocale(clientLocale, this.config.namespace || 'app'); } } else if (this.availableLocales.includes(clientLocale)) { // The locale is available (auto-discovered) but not loaded yet // Try to load and set it console.log(`[clientLoad] Attempting to load saved locale: ${clientLocale}`); try { await this.setLocale(clientLocale); } catch (error) { console.error(`[clientLoad] Failed to load saved locale ${clientLocale}:`, error); // Fallback to default locale const fallbackLocale = this.locales[0] || 'en'; await this.setLocale(fallbackLocale); } } else if (!this.locales.includes(this.currentLocale)) { const fallbackLocale = this.locales[0] || 'en'; await this.setLocale(fallbackLocale); } // Mark locale as restored this.localeRestored = true; } else { console.log('[clientLoad] Skipping locale restoration (already handled by caller or previously restored)'); } } } let globalInstance = null; const namespacedInstances = new SvelteMap(); /** * Clear all instances (for testing) */ export function clearAllInstances() { globalInstance = null; namespacedInstances.clear(); mainAppConfig = null; } // Store main app config for inheritance let mainAppConfig = null; // Global locale state for synchronization across all instances // This acts as the single source of truth for locale across all i18n instances class GlobalLocaleManager { static instance; locale = $state(''); syncEnabled = true; subscribers = new Map(); static getInstance() { if (!GlobalLocaleManager.instance) { GlobalLocaleManager.instance = new GlobalLocaleManager(); } return GlobalLocaleManager.instance; } getLocale() { return this.locale; } setLocale(locale, source) { if (!this.syncEnabled) return; console.log(`[GlobalLocaleManager] Setting global locale to ${locale} from ${source || 'unknown'}`); this.locale = locale; // Notify all subscribers this.subscribers.forEach((callback, namespace) => { if (namespace !== source) { // Don't notify the source console.log(`[GlobalLocaleManager] Notifying ${namespace} of locale change to ${locale}`); callback(locale); } }); } subscribe(namespace, callback) { console.log(`[GlobalLocaleManager] ${namespace} subscribed to locale changes`); this.subscribers.set(namespace, callback); } unsubscribe(namespace) { this.subscribers.delete(namespace); } // Temporarily disable sync during initialization withSyncDisabled(fn) { const prevState = this.syncEnabled; this.syncEnabled = false; fn(); this.syncEnabled = prevState; } } const globalLocaleManager = GlobalLocaleManager.getInstance(); // Make instances globally accessible for library components if (typeof globalThis !== 'undefined') { globalThis.__i18n_instances = namespacedInstances; globalThis.__i18n_locale_manager = globalLocaleManager; } export function setupI18n(config) { // If this is a namespaced instance, create a separate instance for it if (config.namespace) { const existing = namespacedInstances.get(config.namespace); if (existing) { return existing; } // Inherit configuration from main app if available let effectiveConfig = config; if (mainAppConfig) { effectiveConfig = { ...config, // Inherit locale settings from main app defaultLocale: mainAppConfig.defaultLocale, fallbackLocale: mainAppConfig.fallbackLocale, // Inherit formatting and interpolation settings interpolation: config.interpolation || mainAppConfig.interpolation, formats: config.formats || mainAppConfig.formats, pluralization: config.pluralization || mainAppConfig.pluralization, // Keep the package's namespace namespace: config.namespace }; } const instance = new I18nStore(effectiveConfig); namespacedInstances.set(config.namespace, instance); return instance; } // For the global instance, reuse if it already exists // This prevents losing translations during client-side navigation if (globalInstance) { return globalInstance; } // Store as main app config for inheritance mainAppConfig = config; globalInstance = new I18nStore(config); // Also store the global instance in namespacedInstances with 'app' key // This allows library components to access it via __i18n_instances if (config.namespace === 'app') { namespacedInstances.set('app', globalInstance); } return globalInstance; } export function getI18n(namespace) { // If namespace is provided, get the namespaced instance if (namespace) { const instance = namespacedInstances.get(namespace); if (!instance) { throw new Error(`i18n instance for namespace "${namespace}" not found. Call setupI18n with this namespace first.`); } return instance; } // Otherwise, return the global instance if (!globalInstance) { throw new Error('i18n not initialized. Call setupI18n first.'); } return globalInstance; } // Test utility to reset global state export function resetI18nForTesting() { // Reset clientLoadCalled flag before clearing instances if (globalInstance) { globalInstance.clientLoadCalled = false; } namespacedInstances.forEach((instance) => { instance.clientLoadCalled = false; }); globalInstance = null; namespacedInstances.clear(); mainAppConfig = null; }