feat: localizations
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"systemMenu": {
|
||||||
|
"adminSettings": "System",
|
||||||
|
"apps": "Applications",
|
||||||
|
"personalSettings": "Settings",
|
||||||
|
"userSettings": "Settings"
|
||||||
|
},
|
||||||
|
"userMenu": {
|
||||||
|
"darkMode": "Dark Mode",
|
||||||
|
"lightMode": "Light Mode",
|
||||||
|
"logout": "Logout",
|
||||||
|
"settings": "Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+8
-1
@@ -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,13 +40,17 @@ 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');
|
||||||
moduleStore.init(payload?.modules ?? {});
|
moduleStore.init(payload?.modules ?? {});
|
||||||
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
@@ -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');
|
||||||
|
})();
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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') {
|
||||||
@@ -104,4 +119,4 @@ export async function initializeModules(app: App): Promise<void> {
|
|||||||
|
|
||||||
// Wait for all dynamic loading to complete
|
// Wait for all dynamic loading to complete
|
||||||
await Promise.all(loadPromises);
|
await Promise.all(loadPromises);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
@@ -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.'
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+89
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user