feat: localizations

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-07 16:56:58 -04:00
parent 6931dc9ff7
commit 0cc255a087
27 changed files with 728 additions and 28 deletions
+11 -2
View File
@@ -2,14 +2,15 @@
namespace KTXC\Controllers; namespace KTXC\Controllers;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\L10N\LocaleResolver;
use KTXC\Module\ModuleManager; use KTXC\Module\ModuleManager;
use KTXC\Security\Authorization\PermissionChecker; use KTXC\Security\Authorization\PermissionChecker;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity; use KTXC\SessionIdentity;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXC\SessionTenant; use KTXC\SessionTenant;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
class InitController extends ControllerAbstract class InitController extends ControllerAbstract
@@ -20,10 +21,11 @@ class InitController extends ControllerAbstract
private readonly ModuleManager $moduleManager, private readonly ModuleManager $moduleManager,
private readonly UserAccountsService $userService, private readonly UserAccountsService $userService,
private readonly PermissionChecker $permissionChecker, private readonly PermissionChecker $permissionChecker,
private readonly LocaleResolver $localeResolver,
) {} ) {}
#[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])] #[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])]
public function index(): JsonResponse { public function index(Request $request): JsonResponse {
$configuration = []; $configuration = [];
@@ -43,6 +45,13 @@ class InitController extends ControllerAbstract
} }
} }
// localization
$configuration['l10n'] = [
'locale' => $this->localeResolver->resolve($request),
'fallback' => LocaleResolver::FALLBACK,
'available' => $this->localeResolver->available(),
];
// tenant // tenant
$configuration['tenant'] = [ $configuration['tenant'] = [
'id' => $this->tenant->identifier(), 'id' => $this->tenant->identifier(),
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace KTXC\L10N;
use DI\Attribute\Inject;
use KTXC\Http\Request\Request;
use KTXC\Service\UserAccountsService;
/**
* Resolves the effective UI locale for the current user.
*
* Resolution chain: user setting (core.locale) -> Accept-Language
* negotiation -> fallback. Tenant default slots in between once tenant
* settings expose one (Phase 3).
*/
class LocaleResolver
{
public const FALLBACK = 'en';
public const SETTING_KEY = 'core.locale';
public function __construct(
private readonly UserAccountsService $userService,
#[Inject('rootDir')] private readonly string $rootDir,
) {}
/**
* Locales with a core catalog on disk (public/l10n/*.json), always
* including the fallback. This backs the language picker, so a locale
* is only offered when it has at least core-shell coverage.
*
* @return string[]
*/
public function available(): array
{
$locales = [self::FALLBACK];
foreach (glob($this->rootDir . '/public/l10n/*.json') ?: [] as $file) {
$locales[] = basename($file, '.json');
}
$locales = array_values(array_unique($locales));
sort($locales);
return $locales;
}
public function resolve(Request $request): string
{
$available = $this->available();
// 1. explicit user preference
$settings = $this->userService->fetchSettings([self::SETTING_KEY]);
$preference = $settings[self::SETTING_KEY] ?? null;
if (is_string($preference) && in_array($preference, $available, true)) {
return $preference;
}
// 2. Accept-Language negotiation. getPreferredLanguage() returns the
// first candidate when nothing matches, so the fallback leads the
// list; it also normalizes to underscore form (pt_BR) while catalogs
// use BCP-47 hyphens.
$candidates = array_merge([self::FALLBACK], array_diff($available, [self::FALLBACK]));
$negotiated = $request->getPreferredLanguage($candidates);
if ($negotiated !== null) {
$negotiated = str_replace('_', '-', $negotiated);
if (in_array($negotiated, $available, true)) {
return $negotiated;
}
}
// 3. fallback
return self::FALLBACK;
}
}
+2
View File
@@ -3,4 +3,6 @@
*/ */
export { useClipboard } from './useClipboard' export { useClipboard } from './useClipboard'
export { useL10n, t } from './useL10n'
export type { L10N, TranslateParams } from './useL10n'
export { useUser } from './useUser' export { useUser } from './useUser'
+42
View File
@@ -0,0 +1,42 @@
/**
* Translation composable over the shared i18n runtime.
* This is the only surface components and modules use; vue-i18n itself
* stays confined to the plugin.
*/
import { i18n } from '@KTXC/l10n/runtime';
export type TranslateParams = Record<string, unknown>;
export interface L10N {
/**
* Translate a catalog key, rendering the inline English default when the
* key is absent from every loaded catalog. Keys are namespaced with the
* handle bound at useL10n(); the `en` catalog is generated from these
* defaults by scripts/l10n-extract.mjs — keep both literal.
*/
t: (key: string, defaultText: string, params?: TranslateParams) => string;
/** Locale-aware date/time formatting (Intl-backed). */
d: typeof i18n.global.d;
/** Locale-aware number formatting (Intl-backed). */
n: typeof i18n.global.n;
}
/**
* Translate a fully-qualified catalog key (one that already carries its
* namespace, e.g. an integration entry's `l10n` key, which the integration
* store prefixes with the module handle). `defaultText` is rendered when the
* key is undefined or absent from every loaded catalog. Such keys are
* dynamic, so their catalog entries live in the module's hand-maintained
* en.manual.json rather than the extracted catalog.
*/
export function t(key: string | undefined, defaultText?: string, params: TranslateParams = {}): string {
if (!key) return defaultText ?? '';
return i18n.global.t(key, params, { default: defaultText ?? key });
}
export function useL10n(namespace: string): L10N {
const scoped = (key: string, defaultText: string, params: TranslateParams = {}): string =>
t(`${namespace}.${key}`, defaultText, params);
return { t: scoped, d: i18n.global.d, n: i18n.global.n };
}
+24
View File
@@ -0,0 +1,24 @@
{
"header": {
"searchPlaceholder": "Hier suchen.."
},
"systemMenu": {
"administration": "Administration",
"applications": "Anwendungen",
"personalSettings": "Persönliche Einstellungen",
"toggleAdmin": "Admin",
"toggleApps": "Apps",
"toggleSettings": "Einstellungen"
},
"notifications": {
"markAllRead": "Alle als gelesen markieren",
"title": "Benachrichtigungen",
"viewAll": "Alle anzeigen"
},
"userMenu": {
"darkMode": "Dunkles Design",
"lightMode": "Helles Design",
"logout": "Abmelden",
"settings": "Einstellungen"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"systemMenu": {
"adminSettings": "System",
"apps": "Applications",
"personalSettings": "Settings",
"userSettings": "Settings"
},
"userMenu": {
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"logout": "Logout",
"settings": "Settings"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { createI18n } from 'vue-i18n';
import { en as vuetifyEn, de as vuetifyDe } from 'vuetify/locale';
export const FALLBACK_LOCALE = 'en';
/**
* The single vue-i18n instance shared by the core shell and all modules.
*/
export const i18n = createI18n({
legacy: false,
globalInjection: false,
locale: FALLBACK_LOCALE,
fallbackLocale: FALLBACK_LOCALE,
missingWarn: import.meta.env.DEV,
fallbackWarn: false,
messages: {
// Vuetify component strings ($vuetify.*) ride along in the same
// instance, consumed through the createVueI18nAdapter in the vuetify
// plugin. Statically imported: adding a locale means adding it here.
en: { $vuetify: vuetifyEn },
de: { $vuetify: vuetifyDe },
},
});
/** Merge a catalog's messages under a namespace for the given locale. */
export function mergeCatalog(locale: string, namespace: string, messages: Record<string, unknown>): void {
i18n.global.mergeLocaleMessage(locale, { [namespace]: messages });
}
/** Switch the active locale of the shared instance. */
export function applyLocale(locale: string): void {
i18n.global.locale.value = locale;
}
+8 -6
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useLayoutStore } from '@KTXC/stores/layoutStore'; import { useLayoutStore } from '@KTXC/stores/layoutStore';
import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { useL10n } from '@KTXC/composables/useL10n';
import Logo from '@KTXC/layouts/logo/LogoDark.vue'; import Logo from '@KTXC/layouts/logo/LogoDark.vue';
import SystemMenuGroupStatic from './LayoutSystemMenuGroupStatic.vue'; import SystemMenuGroupStatic from './LayoutSystemMenuGroupStatic.vue';
import SystemMenuGroupDynamic from './LayoutSystemMenuGroupDynamic.vue'; import SystemMenuGroupDynamic from './LayoutSystemMenuGroupDynamic.vue';
@@ -9,6 +10,7 @@ import SystemMenuItem from './LayoutSystemMenuItem.vue';
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const integrationStore = useIntegrationStore(); const integrationStore = useIntegrationStore();
const { t } = useL10n('core');
// Get all entries based on current menu mode // Get all entries based on current menu mode
const menuEntries = computed(() => { const menuEntries = computed(() => {
@@ -29,23 +31,23 @@ const menuModeInfo = computed(() => {
case 'user-settings': case 'user-settings':
return { return {
icon: 'mdi-account-cog', icon: 'mdi-account-cog',
label: 'Personal Settings', label: t('systemMenu.personalSettings', 'Settings'),
toggleLabel: 'Admin', toggleLabel: t('systemMenu.adminSettings', 'System'),
toggleIcon: 'mdi-shield-crown', toggleIcon: 'mdi-shield-crown',
}; };
case 'admin-settings': case 'admin-settings':
return { return {
icon: 'mdi-shield-crown', icon: 'mdi-shield-crown',
label: 'Administration', label: t('systemMenu.adminSettings', 'System'),
toggleLabel: 'Apps', toggleLabel: t('systemMenu.apps', 'Applications'),
toggleIcon: 'mdi-view-dashboard', toggleIcon: 'mdi-view-dashboard',
}; };
case 'apps': case 'apps':
default: default:
return { return {
icon: 'mdi-view-dashboard', icon: 'mdi-view-dashboard',
label: 'Applications', label: t('systemMenu.apps', 'Applications'),
toggleLabel: 'Settings', toggleLabel: t('systemMenu.userSettings', 'Settings'),
toggleIcon: 'mdi-account-cog', toggleIcon: 'mdi-account-cog',
}; };
} }
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { IntegrationGroup } from '@KTXC/types/integrationTypes'; import type { IntegrationGroup } from '@KTXC/types/integrationTypes';
import { t } from '@KTXC/composables/useL10n';
import NavItem from './LayoutSystemMenuItem.vue'; import NavItem from './LayoutSystemMenuItem.vue';
const props = defineProps<{ group: IntegrationGroup; level?: number }>(); const props = defineProps<{ group: IntegrationGroup; level?: number }>();
@@ -20,7 +21,7 @@ const props = defineProps<{ group: IntegrationGroup; level?: number }>();
<v-icon v-if="group.icon" :icon="group.icon"></v-icon> <v-icon v-if="group.icon" :icon="group.icon"></v-icon>
</template> </template>
<!---Title --> <!---Title -->
<v-list-item-title class="mr-auto">{{ group.label }}</v-list-item-title> <v-list-item-title class="mr-auto">{{ t(group.l10n, group.label) }}</v-list-item-title>
<!---If Caption--> <!---If Caption-->
<v-list-item-subtitle v-if="group.caption" class="text-caption mt-n1 hide-menu"> <v-list-item-subtitle v-if="group.caption" class="text-caption mt-n1 hide-menu">
{{ group.caption }} {{ group.caption }}
@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { IntegrationGroup } from '@KTXC/types/integrationTypes'; import type { IntegrationGroup } from '@KTXC/types/integrationTypes';
import { t } from '@KTXC/composables/useL10n';
import LayoutSystemMenuItem from './LayoutSystemMenuItem.vue'; import LayoutSystemMenuItem from './LayoutSystemMenuItem.vue';
const props = defineProps<{ group: IntegrationGroup }>(); const props = defineProps<{ group: IntegrationGroup }>();
</script> </script>
<template> <template>
<v-list-subheader color="lightText" class="smallCap text-subtitle-2">{{ props.group.label }}</v-list-subheader> <v-list-subheader color="lightText" class="smallCap text-subtitle-2">{{ t(props.group.l10n, props.group.label) }}</v-list-subheader>
<LayoutSystemMenuItem <LayoutSystemMenuItem
v-for="(item, i) in props.group.items" v-for="(item, i) in props.group.items"
:key="i" :key="i"
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { IntegrationItem } from '@KTXC/types/integrationTypes'; import type { IntegrationItem } from '@KTXC/types/integrationTypes';
import { t } from '@KTXC/composables/useL10n';
const props = defineProps<{ item: IntegrationItem; level?: number }>(); const props = defineProps<{ item: IntegrationItem; level?: number }>();
</script> </script>
@@ -20,7 +21,7 @@ const props = defineProps<{ item: IntegrationItem; level?: number }>();
<template v-slot:prepend> <template v-slot:prepend>
<v-icon v-if="props.item.icon" :icon="props.item.icon"></v-icon> <v-icon v-if="props.item.icon" :icon="props.item.icon"></v-icon>
</template> </template>
<v-list-item-title>{{ item.label }}</v-list-item-title> <v-list-item-title>{{ t(item.l10n, item.label) }}</v-list-item-title>
<!---If Caption--> <!---If Caption-->
<v-list-item-subtitle v-if="item.caption" class="text-caption mt-n1 hide-menu"> <v-list-item-subtitle v-if="item.caption" class="text-caption mt-n1 hide-menu">
{{ item.caption }} {{ item.caption }}
+7 -4
View File
@@ -5,8 +5,11 @@ import { useUserStore } from '@KTXC/stores/userStore';
import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { useLayoutStore } from '@KTXC/stores/layoutStore'; import { useLayoutStore } from '@KTXC/stores/layoutStore';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useL10n, t as tGlobal } from '@KTXC/composables/useL10n';
import defaultAvatar from '@KTXC/assets/images/users/avatar-1.png'; import defaultAvatar from '@KTXC/assets/images/users/avatar-1.png';
const { t } = useL10n('core');
const theme = useTheme(); const theme = useTheme();
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
@@ -74,7 +77,7 @@ const goToSettings = () => {
<template v-slot:prepend> <template v-slot:prepend>
<v-icon v-if="item.icon">{{ item.icon }}</v-icon> <v-icon v-if="item.icon">{{ item.icon }}</v-icon>
</template> </template>
<v-list-item-title class="text-h6">{{ item.label }}</v-list-item-title> <v-list-item-title class="text-h6">{{ tGlobal(item.l10n, item.label) }}</v-list-item-title>
</v-list-item> </v-list-item>
<v-divider v-if="profileMenuItems.length" class="my-2" /> <v-divider v-if="profileMenuItems.length" class="my-2" />
@@ -84,7 +87,7 @@ const goToSettings = () => {
<template v-slot:prepend> <template v-slot:prepend>
<v-icon>{{ isDarkMode ? 'mdi-weather-sunny' : 'mdi-weather-night' }}</v-icon> <v-icon>{{ isDarkMode ? 'mdi-weather-sunny' : 'mdi-weather-night' }}</v-icon>
</template> </template>
<v-list-item-title class="text-h6">{{ isDarkMode ? 'Light Mode' : 'Dark Mode' }}</v-list-item-title> <v-list-item-title class="text-h6">{{ isDarkMode ? t('userMenu.lightMode', 'Light Mode') : t('userMenu.darkMode', 'Dark Mode') }}</v-list-item-title>
</v-list-item> </v-list-item>
<!-- Go to Settings --> <!-- Go to Settings -->
@@ -92,7 +95,7 @@ const goToSettings = () => {
<template v-slot:prepend> <template v-slot:prepend>
<v-icon>mdi-cog-outline</v-icon> <v-icon>mdi-cog-outline</v-icon>
</template> </template>
<v-list-item-title class="text-h6">Settings</v-list-item-title> <v-list-item-title class="text-h6">{{ t('userMenu.settings', 'Settings') }}</v-list-item-title>
</v-list-item> </v-list-item>
<v-divider class="my-2" /> <v-divider class="my-2" />
@@ -102,7 +105,7 @@ const goToSettings = () => {
<template v-slot:prepend> <template v-slot:prepend>
<v-icon>mdi-logout</v-icon> <v-icon>mdi-logout</v-icon>
</template> </template>
<v-list-item-title class="text-h6">Logout</v-list-item-title> <v-list-item-title class="text-h6">{{ t('userMenu.logout', 'Logout') }}</v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
</perfect-scrollbar> </perfect-scrollbar>
+6
View File
@@ -2,6 +2,9 @@ import { createVuetify } from 'vuetify'
import { VBtn } from 'vuetify/components/VBtn' import { VBtn } from 'vuetify/components/VBtn'
import * as components from 'vuetify/components' import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives' import * as directives from 'vuetify/directives'
import { createVueI18nAdapter } from 'vuetify/locale/adapters/vue-i18n'
import { useI18n } from 'vue-i18n'
import { i18n } from '@KTXC/l10n/runtime'
import defaults from './defaults' import defaults from './defaults'
import { icons } from './icons' import { icons } from './icons'
import { themes } from './theme' import { themes } from './theme'
@@ -17,6 +20,9 @@ export default createVuetify({
}, },
defaults, defaults,
icons, icons,
locale: {
adapter: createVueI18nAdapter({ i18n, useI18n }),
},
theme: { theme: {
defaultTheme: 'light', defaultTheme: 'light',
themes, themes,
+7
View File
@@ -7,6 +7,8 @@ import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
import { useModuleStore } from '@KTXC/stores/moduleStore' import { useModuleStore } from '@KTXC/stores/moduleStore'
import { useTenantStore } from '@KTXC/stores/tenantStore' import { useTenantStore } from '@KTXC/stores/tenantStore'
import { useUserStore } from '@KTXC/stores/userStore' import { useUserStore } from '@KTXC/stores/userStore'
import { useL10nStore } from '@KTXC/stores/l10nStore'
import { i18n } from '@KTXC/l10n/runtime'
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper' import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'
import { FetchError } from '@KTXC/utils/helpers/fetch-wrapper-core' import { FetchError } from '@KTXC/utils/helpers/fetch-wrapper-core'
import { initializeModules } from '@KTXC/utils/modules' import { initializeModules } from '@KTXC/utils/modules'
@@ -22,6 +24,7 @@ const app = createApp(App)
const pinia = createPinia() const pinia = createPinia()
app.use(pinia) app.use(pinia)
app.use(PerfectScrollbarPlugin) app.use(PerfectScrollbarPlugin)
app.use(i18n)
app.use(vuetify) app.use(vuetify)
const globalWindow = window as typeof window & { const globalWindow = window as typeof window & {
@@ -37,6 +40,7 @@ globalWindow.Pinia = PiniaLib as unknown
const moduleStore = useModuleStore(); const moduleStore = useModuleStore();
const tenantStore = useTenantStore(); const tenantStore = useTenantStore();
const userStore = useUserStore(); const userStore = useUserStore();
const l10nStore = useL10nStore();
try { try {
const payload = await fetchWrapper.get('/init'); const payload = await fetchWrapper.get('/init');
@@ -44,6 +48,9 @@ globalWindow.Pinia = PiniaLib as unknown
tenantStore.init(payload?.tenant ?? null); tenantStore.init(payload?.tenant ?? null);
userStore.init(payload?.user ?? {}); userStore.init(payload?.user ?? {});
// Resolve locale and load catalogs before modules boot
await l10nStore.init(payload?.l10n ?? null);
// Initialize auth session monitor // Initialize auth session monitor
const sessionMonitor = createSessionMonitor({ onLogout: () => userStore.logout() }); const sessionMonitor = createSessionMonitor({ onLogout: () => userStore.logout() });
sessionMonitor.start(); sessionMonitor.start();
+8 -1
View File
@@ -3,6 +3,8 @@ import { createPinia } from 'pinia';
import App from './App.vue'; import App from './App.vue';
import { router } from './router'; import { router } from './router';
import { AUTH_STORAGE_KEY } from '@KTXC/stores/userStore'; import { AUTH_STORAGE_KEY } from '@KTXC/stores/userStore';
import { useL10nStore } from '@KTXC/stores/l10nStore';
import { i18n } from '@KTXC/l10n/runtime';
import vuetify from './plugins/vuetify/index'; import vuetify from './plugins/vuetify/index';
// Material Design Icons (Vuetify mdi icon set) // Material Design Icons (Vuetify mdi icon set)
@@ -19,6 +21,11 @@ const app = createApp(App);
const pinia = createPinia(); const pinia = createPinia();
app.use(pinia); app.use(pinia);
app.use(router); app.use(router);
app.use(i18n);
app.use(vuetify); app.use(vuetify);
app.mount('#app'); (async () => {
// No session yet: locale comes from browser preferences alone
await useL10nStore().init(null);
app.mount('#app');
})();
+3
View File
@@ -12,8 +12,11 @@ export { useTenantStore } from '../stores/tenantStore'
export { useUserStore } from '../stores/userStore' export { useUserStore } from '../stores/userStore'
export { useIntegrationStore } from '../stores/integrationStore' export { useIntegrationStore } from '../stores/integrationStore'
export { useLayoutStore } from '../stores/layoutStore' export { useLayoutStore } from '../stores/layoutStore'
export { useL10nStore } from '../stores/l10nStore'
// Composables // Composables
export { useL10n, t } from '../composables/useL10n'
export type { L10N, TranslateParams } from '../composables/useL10n'
export { useUser } from '../composables/useUser' export { useUser } from '../composables/useUser'
export { useClipboard } from '../composables/useClipboard' export { useClipboard } from '../composables/useClipboard'
export { useSnackbar } from '../composables/useSnackbar' export { useSnackbar } from '../composables/useSnackbar'
+5
View File
@@ -65,6 +65,11 @@ export const useIntegrationStore = defineStore('integrationStore', {
// Remove 'type' field as it's only used for module-side disambiguation // Remove 'type' field as it's only used for module-side disambiguation
delete prefixed.type; delete prefixed.type;
// Prefix localization keys with the module namespace
if (entry.l10n) {
prefixed.l10n = `${moduleHandle}.${entry.l10n}`;
}
// Prefix internal paths // Prefix internal paths
if (entry.path) { if (entry.path) {
prefixed.to = `/m/${moduleHandle}${entry.path}`; prefixed.to = `/m/${moduleHandle}${entry.path}`;
+151
View File
@@ -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,
};
});
+4
View File
@@ -13,6 +13,8 @@ export interface IntegrationItem {
moduleHandle: string; moduleHandle: string;
priority?: number; priority?: number;
label?: string; label?: string;
/** Localization key for the label (module-relative; prefixed with the handle on registration). `label` is its fallback. */
l10n?: string;
caption?: string; caption?: string;
icon?: string; icon?: string;
to?: string; to?: string;
@@ -30,6 +32,8 @@ export interface IntegrationGroup {
moduleHandle: string; moduleHandle: string;
priority?: number; priority?: number;
label?: string; label?: string;
/** Localization key for the label (module-relative; prefixed with the handle on registration). `label` is its fallback. */
l10n?: string;
caption?: string; caption?: string;
icon?: string; icon?: string;
style?: IntegrationGroupStyle; style?: IntegrationGroupStyle;
+22 -7
View File
@@ -2,6 +2,8 @@ import type { App } from 'vue';
import { router } from '@KTXC/router'; 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 type { CatalogMessages } 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];
@@ -46,12 +48,20 @@ function installModuleRoutes(moduleHandle: string, routes: any[]): void {
}); });
} }
function installModuleIntegrations( function installModuleIntegrations(moduleHandle: string, integrations: Record<string, any[]>): void {
moduleHandle: string, useIntegrationStore().registerModuleIntegrations(moduleHandle, integrations);
integrations: Record<string, any[]> }
): void {
const integrationStore = useIntegrationStore(); async function installModuleLocalizations(moduleHandle: string): Promise<void> {
integrationStore.registerModuleIntegrations(moduleHandle, integrations); await useL10nStore().registerCatalogProvider(moduleHandle, (locale) => loadModuleLocalizations(moduleHandle, locale));
}
async function loadModuleLocalizations(moduleHandle: string, locale: string): Promise<CatalogMessages | null> {
const response = await fetch(
`/modules/${moduleHandle}/static/l10n/${locale}.json`,
{ headers: { Accept: 'application/json' } },
);
return response.ok ? await response.json() : null;
} }
export async function initializeModules(app: App): Promise<void> { export async function initializeModules(app: App): Promise<void> {
@@ -72,11 +82,16 @@ export async function initializeModules(app: App): Promise<void> {
console.log(`Module Loader - Loading ${moduleInfo.handle} from ${moduleUrl}`); console.log(`Module Loader - Loading ${moduleInfo.handle} from ${moduleUrl}`);
const loadPromise = import(/* @vite-ignore */ moduleUrl) const loadPromise = import(/* @vite-ignore */ moduleUrl)
.then((module) => { .then(async (module) => {
// Load CSS if module explicitly exports css path(s) // Load CSS if module explicitly exports css path(s)
if (module.css) { if (module.css) {
installModuleCSS(moduleInfo.handle, module.css); installModuleCSS(moduleInfo.handle, module.css);
} }
// Load translation catalogs if module explicitly exports l10n flag
if (module.l10n) {
console.log(`Module Loader - Installing Catalogs ${moduleInfo.handle}`);
await installModuleLocalizations(moduleHandle);
}
// install module // install module
console.log(`Module Loader - Installing ${moduleInfo.handle}`); console.log(`Module Loader - Installing ${moduleInfo.handle}`);
if (module.default && typeof module.default.install === 'function') { if (module.default && typeof module.default.install === 'function') {
+3 -1
View File
@@ -3,12 +3,14 @@ import { RouterView } from 'vue-router';
import LayoutHeader from '@KTXC/layouts/header/LayoutHeader.vue'; import LayoutHeader from '@KTXC/layouts/header/LayoutHeader.vue';
import { useLayoutStore } from '@KTXC/stores/layoutStore'; import { useLayoutStore } from '@KTXC/stores/layoutStore';
import LayoutSystemMenu from '@KTXC/layouts/menus/LayoutSystemMenu.vue'; import LayoutSystemMenu from '@KTXC/layouts/menus/LayoutSystemMenu.vue';
import { useL10nStore } from '@KTXC/stores/l10nStore';
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const l10nStore = useL10nStore();
</script> </script>
<template> <template>
<v-locale-provider> <v-locale-provider :rtl="l10nStore.isRtl">
<v-app :class="[layoutStore.miniSidebar ? 'mini-sidebar' : '']"> <v-app :class="[layoutStore.miniSidebar ? 'mini-sidebar' : '']">
<LayoutHeader /> <LayoutHeader />
<LayoutSystemMenu /> <LayoutSystemMenu />
+3 -1
View File
@@ -2,12 +2,14 @@
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import LayoutFooter from '@KTXC/layouts/footer/LayoutFooter.vue'; import LayoutFooter from '@KTXC/layouts/footer/LayoutFooter.vue';
import { useLayoutStore } from '@KTXC/stores/layoutStore'; import { useLayoutStore } from '@KTXC/stores/layoutStore';
import { useL10nStore } from '@KTXC/stores/l10nStore';
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const l10nStore = useL10nStore();
</script> </script>
<template> <template>
<v-locale-provider> <v-locale-provider :rtl="l10nStore.isRtl">
<v-app :class="[layoutStore.miniSidebar ? 'mini-sidebar' : '']"> <v-app :class="[layoutStore.miniSidebar ? 'mini-sidebar' : '']">
<v-main class="page-wrapper"> <v-main class="page-wrapper">
<v-container fluid> <v-container fluid>
+15 -1
View File
@@ -14,5 +14,19 @@ export default [
}, },
pluginJs.configs.recommended, pluginJs.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
...pluginVue.configs['flat/essential'] ...pluginVue.configs['flat/essential'],
{
// The l10n engine is an implementation detail of core/src/plugins/l10n
// (the vuetify plugin adapter is the one sanctioned exception). All
// other code must use the '@KTXC' useL10n() surface.
ignores: ['core/src/plugins/l10n/**', 'core/src/plugins/vuetify/**'],
rules: {
'no-restricted-imports': ['error', {
paths: [{
name: 'vue-i18n',
message: 'Import useL10n from @KTXC instead; vue-i18n is confined to core/src/plugins/l10n.'
}]
}]
}
}
]; ];
+89
View File
@@ -22,6 +22,7 @@
"vee-validate": "^4.15.1", "vee-validate": "^4.15.1",
"vite-plugin-vuetify": "^2.1.3", "vite-plugin-vuetify": "^2.1.3",
"vue": "^3.5.34", "vue": "^3.5.34",
"vue-i18n": "^11.4.6",
"vue-router": "^5.0.7", "vue-router": "^5.0.7",
"vue3-perfect-scrollbar": "^2.0.0", "vue3-perfect-scrollbar": "^2.0.0",
"vuetify": "^4.0.7" "vuetify": "^4.0.7"
@@ -640,6 +641,67 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@intlify/core-base": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.6.tgz",
"integrity": "sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw==",
"license": "MIT",
"dependencies": {
"@intlify/devtools-types": "11.4.6",
"@intlify/message-compiler": "11.4.6",
"@intlify/shared": "11.4.6"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.6.tgz",
"integrity": "sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.6",
"@intlify/shared": "11.4.6"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.6.tgz",
"integrity": "sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.4.6",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.6.tgz",
"integrity": "sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA==",
"license": "MIT",
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -6405,6 +6467,33 @@
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0"
} }
}, },
"node_modules/vue-i18n": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
"integrity": "sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.6",
"@intlify/devtools-types": "11.4.6",
"@intlify/shared": "11.4.6",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-i18n/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "5.0.7", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.7.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.7.tgz",
+3
View File
@@ -14,6 +14,8 @@
"dev:all": "npm run dev && npm run dev:modules", "dev:all": "npm run dev && npm run dev:modules",
"watch": "vite build --mode development --watch", "watch": "vite build --mode development --watch",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"l10n:extract": "node scripts/l10n-extract.mjs",
"l10n:check": "node scripts/l10n-extract.mjs --check",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"test": "vitest run --config tests/js/vitest.config.ts", "test": "vitest run --config tests/js/vitest.config.ts",
"test:unit": "vitest run --config tests/js/vitest.config.ts", "test:unit": "vitest run --config tests/js/vitest.config.ts",
@@ -34,6 +36,7 @@
"vee-validate": "^4.15.1", "vee-validate": "^4.15.1",
"vite-plugin-vuetify": "^2.1.3", "vite-plugin-vuetify": "^2.1.3",
"vue": "^3.5.34", "vue": "^3.5.34",
"vue-i18n": "^11.4.6",
"vue-router": "^5.0.7", "vue-router": "^5.0.7",
"vue3-perfect-scrollbar": "^2.0.0", "vue3-perfect-scrollbar": "^2.0.0",
"vuetify": "^4.0.7" "vuetify": "^4.0.7"
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env node
/**
* L10N catalog extraction (v1)
*
* Scans source files for hybrid-style translation calls
*
* t('some.key', 'Default English text')
* t('some.key', 'Hello {name}', { name })
*
* and generates the source-locale catalog (en.json) from the inline
* defaults, so the file sent to translators always matches the code.
* Keys are authored WITHOUT the namespace prefix; the runtime adds the
* namespace (module handle / 'core') when merging catalogs.
*
* Entries that can only be referenced with dynamic keys can be maintained
* 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 --check # exit 1 if the file is stale
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function discoverModuleTargets() {
const modulesDir = path.join(rootDir, 'modules');
if (!existsSync(modulesDir)) return [];
const discovered = [];
for (const handle of readdirSync(modulesDir)) {
const moduleDir = path.join(modulesDir, handle);
const sourceDir = path.join(moduleDir, 'src');
if (!existsSync(sourceDir) || !statSync(moduleDir).isDirectory()) continue;
const optedIn = existsSync(path.join(moduleDir, 'l10n'))
|| collectSourceFiles(sourceDir).some((file) => readFileSync(file, 'utf8').includes('useL10n('));
if (optedIn) {
discovered.push({
name: handle,
sourceDir,
catalogFile: path.join(moduleDir, 'l10n/en.json'),
});
}
}
return discovered;
}
const checkMode = process.argv.includes('--check');
// Matches t('key', 'default' / t("key", "default" — not preceded by a
// word character or '.', so i18n.global.t(...) and foo.t(...) are ignored.
const CALL_PATTERN = /(?<![\w$.])t\(\s*(['"])((?:\\.|(?!\1).)+?)\1\s*,\s*(['"])((?:\\.|(?!\3).)*?)\3/g;
const SOURCE_EXTENSIONS = new Set(['.ts', '.vue']);
const SKIP_DIRS = new Set(['node_modules', 'l10n', 'static', 'dist']);
function collectSourceFiles(dir, files = []) {
for (const entry of readdirSync(dir)) {
const fullPath = path.join(dir, entry);
if (statSync(fullPath).isDirectory()) {
if (!SKIP_DIRS.has(entry)) collectSourceFiles(fullPath, files);
} else if (SOURCE_EXTENSIONS.has(path.extname(entry))) {
files.push(fullPath);
}
}
return files;
}
function unescape(literal) {
return literal.replace(/\\(['"\\])/g, '$1');
}
function extractTarget(target) {
const entries = new Map(); // key -> { default, file }
let hasErrors = false;
for (const file of collectSourceFiles(target.sourceDir)) {
const source = readFileSync(file, 'utf8');
for (const match of source.matchAll(CALL_PATTERN)) {
const key = unescape(match[2]);
const defaultText = unescape(match[4]);
const relFile = path.relative(rootDir, file);
if (!/^[\w-]+(\.[\w-]+)*$/.test(key)) {
console.error(`${relFile}: invalid key "${key}" (use dot-separated word segments)`);
hasErrors = true;
continue;
}
const existing = entries.get(key);
if (existing && existing.default !== defaultText) {
console.error(
`✗ key "${key}" has conflicting defaults:\n` +
` "${existing.default}" (${existing.file})\n` +
` "${defaultText}" (${relFile})`
);
hasErrors = true;
continue;
}
entries.set(key, { default: defaultText, file: relFile });
}
}
// Nest dotted keys into an object tree
const catalog = {};
for (const key of [...entries.keys()].sort()) {
const segments = key.split('.');
let node = catalog;
for (const segment of segments.slice(0, -1)) {
if (typeof node[segment] === 'string') {
console.error(`✗ key "${key}" nests under "${segment}", which is already a message`);
hasErrors = true;
node = null;
break;
}
node = node[segment] ??= {};
}
if (node) node[segments.at(-1)] = entries.get(key).default;
}
// Merge hand-maintained entries for dynamic keys
const manualFile = path.join(path.dirname(target.catalogFile), 'en.manual.json');
if (existsSync(manualFile)) {
deepMerge(catalog, JSON.parse(readFileSync(manualFile, 'utf8')));
}
return { catalog, count: entries.size, hasErrors };
}
function deepMerge(base, extra) {
for (const [key, value] of Object.entries(extra)) {
if (value && typeof value === 'object' && base[key] && typeof base[key] === 'object') {
deepMerge(base[key], value);
} else {
base[key] = value;
}
}
}
function sortDeep(node) {
if (typeof node !== 'object' || node === null) return node;
return Object.fromEntries(
Object.keys(node).sort().map((key) => [key, sortDeep(node[key])])
);
}
// Extraction targets: source tree -> catalog file. Core always; a module is
// a target once it opts in — has an l10n/ dir or calls useL10n in its src/.
const targets = [
{
name: 'core',
sourceDir: path.join(rootDir, 'core/src'),
catalogFile: path.join(rootDir, 'core/src/l10n/en.json'),
},
...discoverModuleTargets(),
];
let failed = false;
for (const target of targets) {
const { catalog, count, hasErrors } = extractTarget(target);
if (hasErrors) {
failed = true;
continue;
}
const output = JSON.stringify(sortDeep(catalog), null, 2) + '\n';
const relCatalog = path.relative(rootDir, target.catalogFile);
if (checkMode) {
const current = existsSync(target.catalogFile) ? readFileSync(target.catalogFile, 'utf8') : '';
if (current !== output) {
console.error(`${relCatalog} is out of sync with source — run: npm run l10n:extract`);
failed = true;
} else {
console.log(`${relCatalog} in sync (${count} keys)`);
}
} else {
mkdirSync(path.dirname(target.catalogFile), { recursive: true });
writeFileSync(target.catalogFile, output);
console.log(`✓ wrote ${relCatalog} (${count} keys)`);
}
}
process.exit(failed ? 1 : 0);
+5
View File
@@ -50,6 +50,11 @@ export default defineConfig(({ mode }) => ({
dest: '.', dest: '.',
rename: { stripBase: true }, rename: { stripBase: true },
}, },
{
src: path.resolve(__dirname, 'core/src/l10n/*.json'),
dest: 'l10n',
rename: { stripBase: true },
},
], ],
}), }),
], ],