import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import { DEFAULT_LOCALE } from '@KTXC/l10n/config'; import { mergeCatalog, applyLocale, applyFallbackLocale } from '@KTXC/l10n/runtime'; import { useUserStore } from '@KTXC/stores/userStore'; const CORE_NAMESPACE = 'core'; const SETTING_KEY = 'core.locale'; const RTL_LANGUAGES = ['ar', 'fa', 'he', 'ur']; export type TranslationCatalog = Record; export type TranslationLoader = (locale: string) => Promise; export interface L10nInitPayload { locale?: string; fallback?: string; available?: string[]; } export const useL10nStore = defineStore('l10nStore', () => { const locale = ref(DEFAULT_LOCALE); const fallbackLocale = ref(DEFAULT_LOCALE); const available = ref([DEFAULT_LOCALE]); const isRtl = computed(() => RTL_LANGUAGES.includes(locale.value.split('-')[0])); // Translation loaders know where their namespace's messages come from. The // store only coordinates them as the active locale changes. const translationLoaders = new Map([ [CORE_NAMESPACE, async (target: string) => { const response = await fetch(`/l10n/${target}.json`, { headers: { Accept: 'application/json' }, }); return response.ok ? await response.json() : null; }], ]); // catalog fetch results per "namespace:locale": true = merged, false = not on server const catalogState = new Map(); async function loadCatalog(namespace: string, target: string): Promise { const cacheKey = `${namespace}:${target}`; const known = catalogState.get(cacheKey); if (known !== undefined) return known; const loader = translationLoaders.get(namespace); if (!loader) return false; try { const messages = await loader(target); if (!messages) { catalogState.set(cacheKey, false); return false; } mergeCatalog(target, namespace, messages); catalogState.set(cacheKey, true); return true; } catch (error) { // network error: leave state unset so a later attempt can retry console.warn(`L10N - Failed to load ${namespace} catalog for ${target}:`, error); return false; } } async function loadCoreCatalog(target: string): Promise { return loadCatalog(CORE_NAMESPACE, target); } /** Load every registered namespace (core + modules) for a locale. */ async function loadCatalogs(target: string): Promise { await Promise.all( [...translationLoaders.keys()].map((namespace) => loadCatalog(namespace, target)), ); } /** * Register a namespace-specific translation loader. The caller owns catalog * discovery and transport; this store owns locale orchestration and merging. */ async function registerLoader(namespace: string, loader: TranslationLoader): Promise { translationLoaders.set(namespace, loader); for (const cacheKey of catalogState.keys()) { if (cacheKey.startsWith(`${namespace}:`)) catalogState.delete(cacheKey); } await loadCatalog(namespace, fallbackLocale.value); if (locale.value !== fallbackLocale.value) { await loadCatalog(namespace, locale.value); } } async function activate(target: string): Promise { await loadCatalogs(fallbackLocale.value); if (target !== fallbackLocale.value) { await loadCatalogs(target); } locale.value = target; applyLocale(target); document.documentElement.setAttribute('lang', target); document.documentElement.setAttribute('dir', isRtl.value ? 'rtl' : 'ltr'); } /** * Locale candidates from browser preferences: each tag plus its base * language, in preference order ('de-AT' -> 'de-AT', 'de'). */ function browserCandidates(): string[] { const candidates: string[] = []; for (const tag of navigator.languages ?? [navigator.language]) { if (!tag) continue; const base = tag.split('-')[0]; if (!candidates.includes(tag)) candidates.push(tag); if (!candidates.includes(base)) candidates.push(base); } return candidates; } /** * Bootstrap from the /init l10n block (private shell), or from browser * preferences alone when there is no session (public shell). */ async function init(payload?: L10nInitPayload | null): Promise { if (payload?.fallback) { fallbackLocale.value = payload.fallback; applyFallbackLocale(payload.fallback); } if (payload?.available?.length) { available.value = payload.available; } if (payload?.locale) { await activate(payload.locale); return; } // No resolved locale: probe browser candidates against served catalogs for (const candidate of browserCandidates()) { if (candidate === fallbackLocale.value || (await loadCoreCatalog(candidate))) { await activate(candidate); return; } } await activate(fallbackLocale.value); } /** Switch locale; persists as the user's `core.locale` setting by default. */ async function setLocale(target: string, persist = true): Promise { await activate(target); if (persist) { useUserStore().setSetting(SETTING_KEY, target); } } return { locale, available, isRtl, init, setLocale, registerLoader, }; });