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