Files
server/core/src/stores/l10nStore.ts
T
2026-07-07 17:57:43 -04:00

158 lines
5.3 KiB
TypeScript

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<string, unknown>;
export type TranslationLoader = (locale: string) => Promise<TranslationCatalog | null>;
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<string[]>([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<string, TranslationLoader>([
[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<string, boolean>();
async function loadCatalog(namespace: string, target: string): Promise<boolean> {
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<boolean> {
return loadCatalog(CORE_NAMESPACE, target);
}
/** Load every registered namespace (core + modules) for a locale. */
async function loadCatalogs(target: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await activate(target);
if (persist) {
useUserStore().setSetting(SETTING_KEY, target);
}
}
return {
locale,
available,
isRtl,
init,
setLocale,
registerLoader,
};
});