diff --git a/core/src/l10n/de.json b/core/l10n/de.json similarity index 100% rename from core/src/l10n/de.json rename to core/l10n/de.json diff --git a/core/src/l10n/en.json b/core/l10n/en.json similarity index 100% rename from core/src/l10n/en.json rename to core/l10n/en.json diff --git a/core/src/l10n/config.ts b/core/src/l10n/config.ts new file mode 100644 index 0000000..7cbd86a --- /dev/null +++ b/core/src/l10n/config.ts @@ -0,0 +1,2 @@ +/** Locale used before runtime localization configuration is available. */ +export const DEFAULT_LOCALE = 'en'; diff --git a/core/src/l10n/runtime.ts b/core/src/l10n/runtime.ts index 2a002bc..9ce639c 100644 --- a/core/src/l10n/runtime.ts +++ b/core/src/l10n/runtime.ts @@ -1,7 +1,6 @@ import { createI18n } from 'vue-i18n'; import { en as vuetifyEn, de as vuetifyDe } from 'vuetify/locale'; - -export const FALLBACK_LOCALE = 'en'; +import { DEFAULT_LOCALE } from '@KTXC/l10n/config'; /** * The single vue-i18n instance shared by the core shell and all modules. @@ -9,8 +8,8 @@ export const FALLBACK_LOCALE = 'en'; export const i18n = createI18n({ legacy: false, globalInjection: false, - locale: FALLBACK_LOCALE, - fallbackLocale: FALLBACK_LOCALE, + locale: DEFAULT_LOCALE, + fallbackLocale: DEFAULT_LOCALE, missingWarn: import.meta.env.DEV, fallbackWarn: false, messages: { @@ -31,3 +30,8 @@ export function mergeCatalog(locale: string, namespace: string, messages: Record export function applyLocale(locale: string): void { i18n.global.locale.value = locale; } + +/** Update the locale used when a translation is missing from the active locale. */ +export function applyFallbackLocale(locale: string): void { + i18n.global.fallbackLocale.value = locale; +} diff --git a/core/src/stores/l10nStore.ts b/core/src/stores/l10nStore.ts index 7e12815..0bfd5c3 100644 --- a/core/src/stores/l10nStore.ts +++ b/core/src/stores/l10nStore.ts @@ -1,14 +1,15 @@ import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; -import { FALLBACK_LOCALE, mergeCatalog, applyLocale } from '@KTXC/l10n/runtime'; +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 CatalogMessages = Record; -export type CatalogProvider = (locale: string) => Promise; +export type TranslationCatalog = Record; +export type TranslationLoader = (locale: string) => Promise; export interface L10nInitPayload { locale?: string; @@ -17,14 +18,15 @@ export interface L10nInitPayload { } export const useL10nStore = defineStore('l10nStore', () => { - const locale = ref(FALLBACK_LOCALE); - const available = ref([FALLBACK_LOCALE]); + 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])); - // Catalog providers know where their namespace's messages come from. The + // Translation loaders know where their namespace's messages come from. The // store only coordinates them as the active locale changes. - const catalogProviders = new Map([ + const translationLoaders = new Map([ [CORE_NAMESPACE, async (target: string) => { const response = await fetch(`/l10n/${target}.json`, { headers: { Accept: 'application/json' }, @@ -40,10 +42,10 @@ export const useL10nStore = defineStore('l10nStore', () => { const cacheKey = `${namespace}:${target}`; const known = catalogState.get(cacheKey); if (known !== undefined) return known; - const provider = catalogProviders.get(namespace); - if (!provider) return false; + const loader = translationLoaders.get(namespace); + if (!loader) return false; try { - const messages = await provider(target); + const messages = await loader(target); if (!messages) { catalogState.set(cacheKey, false); return false; @@ -65,28 +67,28 @@ export const useL10nStore = defineStore('l10nStore', () => { /** Load every registered namespace (core + modules) for a locale. */ async function loadCatalogs(target: string): Promise { await Promise.all( - [...catalogProviders.keys()].map((namespace) => loadCatalog(namespace, target)), + [...translationLoaders.keys()].map((namespace) => loadCatalog(namespace, target)), ); } /** - * Register a namespace-specific catalog provider. The caller owns catalog + * Register a namespace-specific translation loader. The caller owns catalog * discovery and transport; this store owns locale orchestration and merging. */ - async function registerCatalogProvider(namespace: string, provider: CatalogProvider): Promise { - catalogProviders.set(namespace, provider); + 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, FALLBACK_LOCALE); - if (locale.value !== FALLBACK_LOCALE) { + await loadCatalog(namespace, fallbackLocale.value); + if (locale.value !== fallbackLocale.value) { await loadCatalog(namespace, locale.value); } } async function activate(target: string): Promise { - await loadCatalogs(FALLBACK_LOCALE); - if (target !== FALLBACK_LOCALE) { + await loadCatalogs(fallbackLocale.value); + if (target !== fallbackLocale.value) { await loadCatalogs(target); } locale.value = target; @@ -115,6 +117,10 @@ export const useL10nStore = defineStore('l10nStore', () => { * 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; } @@ -124,12 +130,12 @@ export const useL10nStore = defineStore('l10nStore', () => { } // No resolved locale: probe browser candidates against served catalogs for (const candidate of browserCandidates()) { - if (candidate === FALLBACK_LOCALE || (await loadCoreCatalog(candidate))) { + if (candidate === fallbackLocale.value || (await loadCoreCatalog(candidate))) { await activate(candidate); return; } } - await activate(FALLBACK_LOCALE); + await activate(fallbackLocale.value); } /** Switch locale; persists as the user's `core.locale` setting by default. */ @@ -146,6 +152,6 @@ export const useL10nStore = defineStore('l10nStore', () => { isRtl, init, setLocale, - registerCatalogProvider, + registerLoader, }; }); diff --git a/core/src/utils/modules.ts b/core/src/utils/modules.ts index 98c5ec5..055beea 100644 --- a/core/src/utils/modules.ts +++ b/core/src/utils/modules.ts @@ -3,7 +3,7 @@ import { router } from '@KTXC/router'; import { useModuleStore } from '@KTXC/stores/moduleStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { useL10nStore } from '@KTXC/stores/l10nStore'; -import type { CatalogMessages } from '@KTXC/stores/l10nStore'; +import type { TranslationCatalog } from '@KTXC/stores/l10nStore'; function installModuleCSS(moduleHandle: string, cssPaths: string | string[]): void { const cssFiles = Array.isArray(cssPaths) ? cssPaths : [cssPaths]; @@ -53,10 +53,10 @@ function installModuleIntegrations(moduleHandle: string, integrations: Record { - await useL10nStore().registerCatalogProvider(moduleHandle, (locale) => loadModuleLocalizations(moduleHandle, locale)); + await useL10nStore().registerLoader(moduleHandle, (locale) => loadModuleLocalizations(moduleHandle, locale)); } -async function loadModuleLocalizations(moduleHandle: string, locale: string): Promise { +async function loadModuleLocalizations(moduleHandle: string, locale: string): Promise { const response = await fetch( `/modules/${moduleHandle}/static/l10n/${locale}.json`, { headers: { Accept: 'application/json' } }, diff --git a/scripts/l10n-extract.mjs b/scripts/l10n-extract.mjs index e3a6279..4c33f2a 100644 --- a/scripts/l10n-extract.mjs +++ b/scripts/l10n-extract.mjs @@ -16,7 +16,7 @@ * by hand in en.manual.json next to the target catalog; they are merged in. * * Usage: - * node scripts/l10n-extract.mjs # write core/src/l10n/en.json + * node scripts/l10n-extract.mjs # write core/l10n/en.json * node scripts/l10n-extract.mjs --check # exit 1 if the file is stale */ @@ -151,7 +151,7 @@ const targets = [ { name: 'core', sourceDir: path.join(rootDir, 'core/src'), - catalogFile: path.join(rootDir, 'core/src/l10n/en.json'), + catalogFile: path.join(rootDir, 'core/l10n/en.json'), }, ...discoverModuleTargets(), ]; diff --git a/vite.config.ts b/vite.config.ts index 7f07f5b..528e8e6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -51,7 +51,7 @@ export default defineConfig(({ mode }) => ({ rename: { stripBase: true }, }, { - src: path.resolve(__dirname, 'core/src/l10n/*.json'), + src: path.resolve(__dirname, 'core/l10n/*.json'), dest: 'l10n', rename: { stripBase: true }, },