feat: localizations
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -65,6 +65,11 @@ export const useIntegrationStore = defineStore('integrationStore', {
|
||||
// Remove 'type' field as it's only used for module-side disambiguation
|
||||
delete prefixed.type;
|
||||
|
||||
// Prefix localization keys with the module namespace
|
||||
if (entry.l10n) {
|
||||
prefixed.l10n = `${moduleHandle}.${entry.l10n}`;
|
||||
}
|
||||
|
||||
// Prefix internal paths
|
||||
if (entry.path) {
|
||||
prefixed.to = `/m/${moduleHandle}${entry.path}`;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { FALLBACK_LOCALE, mergeCatalog, applyLocale } 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 CatalogMessages = Record<string, unknown>;
|
||||
export type CatalogProvider = (locale: string) => Promise<CatalogMessages | null>;
|
||||
|
||||
export interface L10nInitPayload {
|
||||
locale?: string;
|
||||
fallback?: string;
|
||||
available?: string[];
|
||||
}
|
||||
|
||||
export const useL10nStore = defineStore('l10nStore', () => {
|
||||
const locale = ref(FALLBACK_LOCALE);
|
||||
const available = ref<string[]>([FALLBACK_LOCALE]);
|
||||
|
||||
const isRtl = computed(() => RTL_LANGUAGES.includes(locale.value.split('-')[0]));
|
||||
|
||||
// Catalog providers know where their namespace's messages come from. The
|
||||
// store only coordinates them as the active locale changes.
|
||||
const catalogProviders = new Map<string, CatalogProvider>([
|
||||
[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 provider = catalogProviders.get(namespace);
|
||||
if (!provider) return false;
|
||||
try {
|
||||
const messages = await provider(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(
|
||||
[...catalogProviders.keys()].map((namespace) => loadCatalog(namespace, target)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a namespace-specific catalog provider. The caller owns catalog
|
||||
* discovery and transport; this store owns locale orchestration and merging.
|
||||
*/
|
||||
async function registerCatalogProvider(namespace: string, provider: CatalogProvider): Promise<void> {
|
||||
catalogProviders.set(namespace, provider);
|
||||
for (const cacheKey of catalogState.keys()) {
|
||||
if (cacheKey.startsWith(`${namespace}:`)) catalogState.delete(cacheKey);
|
||||
}
|
||||
await loadCatalog(namespace, FALLBACK_LOCALE);
|
||||
if (locale.value !== FALLBACK_LOCALE) {
|
||||
await loadCatalog(namespace, locale.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(target: string): Promise<void> {
|
||||
await loadCatalogs(FALLBACK_LOCALE);
|
||||
if (target !== FALLBACK_LOCALE) {
|
||||
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?.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 === FALLBACK_LOCALE || (await loadCoreCatalog(candidate))) {
|
||||
await activate(candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await activate(FALLBACK_LOCALE);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
registerCatalogProvider,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user