refactor: cleanup translations

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-07 17:57:43 -04:00
parent d92a0331a6
commit 5a729d851e
8 changed files with 43 additions and 31 deletions
+2
View File
@@ -0,0 +1,2 @@
/** Locale used before runtime localization configuration is available. */
export const DEFAULT_LOCALE = 'en';
+8 -4
View File
@@ -1,7 +1,6 @@
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import { en as vuetifyEn, de as vuetifyDe } from 'vuetify/locale'; import { en as vuetifyEn, de as vuetifyDe } from 'vuetify/locale';
import { DEFAULT_LOCALE } from '@KTXC/l10n/config';
export const FALLBACK_LOCALE = 'en';
/** /**
* The single vue-i18n instance shared by the core shell and all modules. * 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({ export const i18n = createI18n({
legacy: false, legacy: false,
globalInjection: false, globalInjection: false,
locale: FALLBACK_LOCALE, locale: DEFAULT_LOCALE,
fallbackLocale: FALLBACK_LOCALE, fallbackLocale: DEFAULT_LOCALE,
missingWarn: import.meta.env.DEV, missingWarn: import.meta.env.DEV,
fallbackWarn: false, fallbackWarn: false,
messages: { messages: {
@@ -31,3 +30,8 @@ export function mergeCatalog(locale: string, namespace: string, messages: Record
export function applyLocale(locale: string): void { export function applyLocale(locale: string): void {
i18n.global.locale.value = locale; 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;
}
+27 -21
View File
@@ -1,14 +1,15 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; 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'; import { useUserStore } from '@KTXC/stores/userStore';
const CORE_NAMESPACE = 'core'; const CORE_NAMESPACE = 'core';
const SETTING_KEY = 'core.locale'; const SETTING_KEY = 'core.locale';
const RTL_LANGUAGES = ['ar', 'fa', 'he', 'ur']; const RTL_LANGUAGES = ['ar', 'fa', 'he', 'ur'];
export type CatalogMessages = Record<string, unknown>; export type TranslationCatalog = Record<string, unknown>;
export type CatalogProvider = (locale: string) => Promise<CatalogMessages | null>; export type TranslationLoader = (locale: string) => Promise<TranslationCatalog | null>;
export interface L10nInitPayload { export interface L10nInitPayload {
locale?: string; locale?: string;
@@ -17,14 +18,15 @@ export interface L10nInitPayload {
} }
export const useL10nStore = defineStore('l10nStore', () => { export const useL10nStore = defineStore('l10nStore', () => {
const locale = ref(FALLBACK_LOCALE); const locale = ref(DEFAULT_LOCALE);
const available = ref<string[]>([FALLBACK_LOCALE]); const fallbackLocale = ref(DEFAULT_LOCALE);
const available = ref<string[]>([DEFAULT_LOCALE]);
const isRtl = computed(() => RTL_LANGUAGES.includes(locale.value.split('-')[0])); 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. // store only coordinates them as the active locale changes.
const catalogProviders = new Map<string, CatalogProvider>([ const translationLoaders = new Map<string, TranslationLoader>([
[CORE_NAMESPACE, async (target: string) => { [CORE_NAMESPACE, async (target: string) => {
const response = await fetch(`/l10n/${target}.json`, { const response = await fetch(`/l10n/${target}.json`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
@@ -40,10 +42,10 @@ export const useL10nStore = defineStore('l10nStore', () => {
const cacheKey = `${namespace}:${target}`; const cacheKey = `${namespace}:${target}`;
const known = catalogState.get(cacheKey); const known = catalogState.get(cacheKey);
if (known !== undefined) return known; if (known !== undefined) return known;
const provider = catalogProviders.get(namespace); const loader = translationLoaders.get(namespace);
if (!provider) return false; if (!loader) return false;
try { try {
const messages = await provider(target); const messages = await loader(target);
if (!messages) { if (!messages) {
catalogState.set(cacheKey, false); catalogState.set(cacheKey, false);
return false; return false;
@@ -65,28 +67,28 @@ export const useL10nStore = defineStore('l10nStore', () => {
/** Load every registered namespace (core + modules) for a locale. */ /** Load every registered namespace (core + modules) for a locale. */
async function loadCatalogs(target: string): Promise<void> { async function loadCatalogs(target: string): Promise<void> {
await Promise.all( 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. * discovery and transport; this store owns locale orchestration and merging.
*/ */
async function registerCatalogProvider(namespace: string, provider: CatalogProvider): Promise<void> { async function registerLoader(namespace: string, loader: TranslationLoader): Promise<void> {
catalogProviders.set(namespace, provider); translationLoaders.set(namespace, loader);
for (const cacheKey of catalogState.keys()) { for (const cacheKey of catalogState.keys()) {
if (cacheKey.startsWith(`${namespace}:`)) catalogState.delete(cacheKey); if (cacheKey.startsWith(`${namespace}:`)) catalogState.delete(cacheKey);
} }
await loadCatalog(namespace, FALLBACK_LOCALE); await loadCatalog(namespace, fallbackLocale.value);
if (locale.value !== FALLBACK_LOCALE) { if (locale.value !== fallbackLocale.value) {
await loadCatalog(namespace, locale.value); await loadCatalog(namespace, locale.value);
} }
} }
async function activate(target: string): Promise<void> { async function activate(target: string): Promise<void> {
await loadCatalogs(FALLBACK_LOCALE); await loadCatalogs(fallbackLocale.value);
if (target !== FALLBACK_LOCALE) { if (target !== fallbackLocale.value) {
await loadCatalogs(target); await loadCatalogs(target);
} }
locale.value = target; locale.value = target;
@@ -115,6 +117,10 @@ export const useL10nStore = defineStore('l10nStore', () => {
* preferences alone when there is no session (public shell). * preferences alone when there is no session (public shell).
*/ */
async function init(payload?: L10nInitPayload | null): Promise<void> { async function init(payload?: L10nInitPayload | null): Promise<void> {
if (payload?.fallback) {
fallbackLocale.value = payload.fallback;
applyFallbackLocale(payload.fallback);
}
if (payload?.available?.length) { if (payload?.available?.length) {
available.value = payload.available; available.value = payload.available;
} }
@@ -124,12 +130,12 @@ export const useL10nStore = defineStore('l10nStore', () => {
} }
// No resolved locale: probe browser candidates against served catalogs // No resolved locale: probe browser candidates against served catalogs
for (const candidate of browserCandidates()) { for (const candidate of browserCandidates()) {
if (candidate === FALLBACK_LOCALE || (await loadCoreCatalog(candidate))) { if (candidate === fallbackLocale.value || (await loadCoreCatalog(candidate))) {
await activate(candidate); await activate(candidate);
return; return;
} }
} }
await activate(FALLBACK_LOCALE); await activate(fallbackLocale.value);
} }
/** Switch locale; persists as the user's `core.locale` setting by default. */ /** Switch locale; persists as the user's `core.locale` setting by default. */
@@ -146,6 +152,6 @@ export const useL10nStore = defineStore('l10nStore', () => {
isRtl, isRtl,
init, init,
setLocale, setLocale,
registerCatalogProvider, registerLoader,
}; };
}); });
+3 -3
View File
@@ -3,7 +3,7 @@ import { router } from '@KTXC/router';
import { useModuleStore } from '@KTXC/stores/moduleStore'; import { useModuleStore } from '@KTXC/stores/moduleStore';
import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { useL10nStore } from '@KTXC/stores/l10nStore'; 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 { function installModuleCSS(moduleHandle: string, cssPaths: string | string[]): void {
const cssFiles = Array.isArray(cssPaths) ? cssPaths : [cssPaths]; const cssFiles = Array.isArray(cssPaths) ? cssPaths : [cssPaths];
@@ -53,10 +53,10 @@ function installModuleIntegrations(moduleHandle: string, integrations: Record<st
} }
async function installModuleLocalizations(moduleHandle: string): Promise<void> { async function installModuleLocalizations(moduleHandle: string): Promise<void> {
await useL10nStore().registerCatalogProvider(moduleHandle, (locale) => loadModuleLocalizations(moduleHandle, locale)); await useL10nStore().registerLoader(moduleHandle, (locale) => loadModuleLocalizations(moduleHandle, locale));
} }
async function loadModuleLocalizations(moduleHandle: string, locale: string): Promise<CatalogMessages | null> { async function loadModuleLocalizations(moduleHandle: string, locale: string): Promise<TranslationCatalog | null> {
const response = await fetch( const response = await fetch(
`/modules/${moduleHandle}/static/l10n/${locale}.json`, `/modules/${moduleHandle}/static/l10n/${locale}.json`,
{ headers: { Accept: 'application/json' } }, { headers: { Accept: 'application/json' } },
+2 -2
View File
@@ -16,7 +16,7 @@
* by hand in en.manual.json next to the target catalog; they are merged in. * by hand in en.manual.json next to the target catalog; they are merged in.
* *
* Usage: * 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 * node scripts/l10n-extract.mjs --check # exit 1 if the file is stale
*/ */
@@ -151,7 +151,7 @@ const targets = [
{ {
name: 'core', name: 'core',
sourceDir: path.join(rootDir, 'core/src'), sourceDir: path.join(rootDir, 'core/src'),
catalogFile: path.join(rootDir, 'core/src/l10n/en.json'), catalogFile: path.join(rootDir, 'core/l10n/en.json'),
}, },
...discoverModuleTargets(), ...discoverModuleTargets(),
]; ];
+1 -1
View File
@@ -51,7 +51,7 @@ export default defineConfig(({ mode }) => ({
rename: { stripBase: true }, rename: { stripBase: true },
}, },
{ {
src: path.resolve(__dirname, 'core/src/l10n/*.json'), src: path.resolve(__dirname, 'core/l10n/*.json'),
dest: 'l10n', dest: 'l10n',
rename: { stripBase: true }, rename: { stripBase: true },
}, },