feat: localizations
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -2,14 +2,15 @@
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\L10N\LocaleResolver;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXC\Security\Authorization\PermissionChecker;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
class InitController extends ControllerAbstract
|
||||
@@ -20,10 +21,11 @@ class InitController extends ControllerAbstract
|
||||
private readonly ModuleManager $moduleManager,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly PermissionChecker $permissionChecker,
|
||||
private readonly LocaleResolver $localeResolver,
|
||||
) {}
|
||||
|
||||
#[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])]
|
||||
public function index(): JsonResponse {
|
||||
public function index(Request $request): JsonResponse {
|
||||
|
||||
$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
|
||||
$configuration['tenant'] = [
|
||||
'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 { useL10n, t } from './useL10n'
|
||||
export type { L10N, TranslateParams } from './useL10n'
|
||||
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 { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import { useL10n } from '@KTXC/composables/useL10n';
|
||||
import Logo from '@KTXC/layouts/logo/LogoDark.vue';
|
||||
import SystemMenuGroupStatic from './LayoutSystemMenuGroupStatic.vue';
|
||||
import SystemMenuGroupDynamic from './LayoutSystemMenuGroupDynamic.vue';
|
||||
@@ -9,6 +10,7 @@ import SystemMenuItem from './LayoutSystemMenuItem.vue';
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const integrationStore = useIntegrationStore();
|
||||
const { t } = useL10n('core');
|
||||
|
||||
// Get all entries based on current menu mode
|
||||
const menuEntries = computed(() => {
|
||||
@@ -29,23 +31,23 @@ const menuModeInfo = computed(() => {
|
||||
case 'user-settings':
|
||||
return {
|
||||
icon: 'mdi-account-cog',
|
||||
label: 'Personal Settings',
|
||||
toggleLabel: 'Admin',
|
||||
label: t('systemMenu.personalSettings', 'Settings'),
|
||||
toggleLabel: t('systemMenu.adminSettings', 'System'),
|
||||
toggleIcon: 'mdi-shield-crown',
|
||||
};
|
||||
case 'admin-settings':
|
||||
return {
|
||||
icon: 'mdi-shield-crown',
|
||||
label: 'Administration',
|
||||
toggleLabel: 'Apps',
|
||||
label: t('systemMenu.adminSettings', 'System'),
|
||||
toggleLabel: t('systemMenu.apps', 'Applications'),
|
||||
toggleIcon: 'mdi-view-dashboard',
|
||||
};
|
||||
case 'apps':
|
||||
default:
|
||||
return {
|
||||
icon: 'mdi-view-dashboard',
|
||||
label: 'Applications',
|
||||
toggleLabel: 'Settings',
|
||||
label: t('systemMenu.apps', 'Applications'),
|
||||
toggleLabel: t('systemMenu.userSettings', 'Settings'),
|
||||
toggleIcon: 'mdi-account-cog',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { IntegrationGroup } from '@KTXC/types/integrationTypes';
|
||||
import { t } from '@KTXC/composables/useL10n';
|
||||
import NavItem from './LayoutSystemMenuItem.vue';
|
||||
|
||||
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>
|
||||
</template>
|
||||
<!---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-->
|
||||
<v-list-item-subtitle v-if="group.caption" class="text-caption mt-n1 hide-menu">
|
||||
{{ group.caption }}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { IntegrationGroup } from '@KTXC/types/integrationTypes';
|
||||
import { t } from '@KTXC/composables/useL10n';
|
||||
import LayoutSystemMenuItem from './LayoutSystemMenuItem.vue';
|
||||
|
||||
const props = defineProps<{ group: IntegrationGroup }>();
|
||||
</script>
|
||||
|
||||
<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
|
||||
v-for="(item, i) in props.group.items"
|
||||
:key="i"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { IntegrationItem } from '@KTXC/types/integrationTypes';
|
||||
import { t } from '@KTXC/composables/useL10n';
|
||||
|
||||
const props = defineProps<{ item: IntegrationItem; level?: number }>();
|
||||
</script>
|
||||
@@ -20,7 +21,7 @@ const props = defineProps<{ item: IntegrationItem; level?: number }>();
|
||||
<template v-slot:prepend>
|
||||
<v-icon v-if="props.item.icon" :icon="props.item.icon"></v-icon>
|
||||
</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-->
|
||||
<v-list-item-subtitle v-if="item.caption" class="text-caption mt-n1 hide-menu">
|
||||
{{ item.caption }}
|
||||
|
||||
@@ -5,8 +5,11 @@ import { useUserStore } from '@KTXC/stores/userStore';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useL10n, t as tGlobal } from '@KTXC/composables/useL10n';
|
||||
import defaultAvatar from '@KTXC/assets/images/users/avatar-1.png';
|
||||
|
||||
const { t } = useL10n('core');
|
||||
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
@@ -74,7 +77,7 @@ const goToSettings = () => {
|
||||
<template v-slot:prepend>
|
||||
<v-icon v-if="item.icon">{{ item.icon }}</v-icon>
|
||||
</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-divider v-if="profileMenuItems.length" class="my-2" />
|
||||
@@ -84,7 +87,7 @@ const goToSettings = () => {
|
||||
<template v-slot:prepend>
|
||||
<v-icon>{{ isDarkMode ? 'mdi-weather-sunny' : 'mdi-weather-night' }}</v-icon>
|
||||
</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>
|
||||
|
||||
<!-- Go to Settings -->
|
||||
@@ -92,7 +95,7 @@ const goToSettings = () => {
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-cog-outline</v-icon>
|
||||
</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-divider class="my-2" />
|
||||
@@ -102,7 +105,7 @@ const goToSettings = () => {
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-logout</v-icon>
|
||||
</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>
|
||||
</perfect-scrollbar>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { createVuetify } from 'vuetify'
|
||||
import { VBtn } from 'vuetify/components/VBtn'
|
||||
import * as components from 'vuetify/components'
|
||||
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 { icons } from './icons'
|
||||
import { themes } from './theme'
|
||||
@@ -17,6 +20,9 @@ export default createVuetify({
|
||||
},
|
||||
defaults,
|
||||
icons,
|
||||
locale: {
|
||||
adapter: createVueI18nAdapter({ i18n, useI18n }),
|
||||
},
|
||||
theme: {
|
||||
defaultTheme: 'light',
|
||||
themes,
|
||||
|
||||
+8
-1
@@ -7,6 +7,8 @@ import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import { useModuleStore } from '@KTXC/stores/moduleStore'
|
||||
import { useTenantStore } from '@KTXC/stores/tenantStore'
|
||||
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 { FetchError } from '@KTXC/utils/helpers/fetch-wrapper-core'
|
||||
import { initializeModules } from '@KTXC/utils/modules'
|
||||
@@ -22,6 +24,7 @@ const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(PerfectScrollbarPlugin)
|
||||
app.use(i18n)
|
||||
app.use(vuetify)
|
||||
|
||||
const globalWindow = window as typeof window & {
|
||||
@@ -37,13 +40,17 @@ globalWindow.Pinia = PiniaLib as unknown
|
||||
const moduleStore = useModuleStore();
|
||||
const tenantStore = useTenantStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const l10nStore = useL10nStore();
|
||||
|
||||
try {
|
||||
const payload = await fetchWrapper.get('/init');
|
||||
moduleStore.init(payload?.modules ?? {});
|
||||
tenantStore.init(payload?.tenant ?? null);
|
||||
userStore.init(payload?.user ?? {});
|
||||
|
||||
// Resolve locale and load catalogs before modules boot
|
||||
await l10nStore.init(payload?.l10n ?? null);
|
||||
|
||||
// Initialize auth session monitor
|
||||
const sessionMonitor = createSessionMonitor({ onLogout: () => userStore.logout() });
|
||||
sessionMonitor.start();
|
||||
|
||||
+8
-1
@@ -3,6 +3,8 @@ import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
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';
|
||||
|
||||
// Material Design Icons (Vuetify mdi icon set)
|
||||
@@ -19,6 +21,11 @@ const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(i18n);
|
||||
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 { useIntegrationStore } from '../stores/integrationStore'
|
||||
export { useLayoutStore } from '../stores/layoutStore'
|
||||
export { useL10nStore } from '../stores/l10nStore'
|
||||
|
||||
// Composables
|
||||
export { useL10n, t } from '../composables/useL10n'
|
||||
export type { L10N, TranslateParams } from '../composables/useL10n'
|
||||
export { useUser } from '../composables/useUser'
|
||||
export { useClipboard } from '../composables/useClipboard'
|
||||
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
|
||||
delete prefixed.type;
|
||||
|
||||
// Prefix localization keys with the module namespace
|
||||
if (entry.l10n) {
|
||||
prefixed.l10n = `${moduleHandle}.${entry.l10n}`;
|
||||
}
|
||||
|
||||
// Prefix internal paths
|
||||
if (entry.path) {
|
||||
prefixed.to = `/m/${moduleHandle}${entry.path}`;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { FALLBACK_LOCALE, mergeCatalog, applyLocale } from '@KTXC/l10n/runtime';
|
||||
import { useUserStore } from '@KTXC/stores/userStore';
|
||||
|
||||
const CORE_NAMESPACE = 'core';
|
||||
const SETTING_KEY = 'core.locale';
|
||||
const RTL_LANGUAGES = ['ar', 'fa', 'he', 'ur'];
|
||||
|
||||
export type CatalogMessages = Record<string, unknown>;
|
||||
export type CatalogProvider = (locale: string) => Promise<CatalogMessages | null>;
|
||||
|
||||
export interface L10nInitPayload {
|
||||
locale?: string;
|
||||
fallback?: string;
|
||||
available?: string[];
|
||||
}
|
||||
|
||||
export const useL10nStore = defineStore('l10nStore', () => {
|
||||
const locale = ref(FALLBACK_LOCALE);
|
||||
const available = ref<string[]>([FALLBACK_LOCALE]);
|
||||
|
||||
const isRtl = computed(() => RTL_LANGUAGES.includes(locale.value.split('-')[0]));
|
||||
|
||||
// Catalog providers know where their namespace's messages come from. The
|
||||
// store only coordinates them as the active locale changes.
|
||||
const catalogProviders = new Map<string, CatalogProvider>([
|
||||
[CORE_NAMESPACE, async (target: string) => {
|
||||
const response = await fetch(`/l10n/${target}.json`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
return response.ok ? await response.json() : null;
|
||||
}],
|
||||
]);
|
||||
|
||||
// catalog fetch results per "namespace:locale": true = merged, false = not on server
|
||||
const catalogState = new Map<string, boolean>();
|
||||
|
||||
async function loadCatalog(namespace: string, target: string): Promise<boolean> {
|
||||
const cacheKey = `${namespace}:${target}`;
|
||||
const known = catalogState.get(cacheKey);
|
||||
if (known !== undefined) return known;
|
||||
const provider = catalogProviders.get(namespace);
|
||||
if (!provider) return false;
|
||||
try {
|
||||
const messages = await provider(target);
|
||||
if (!messages) {
|
||||
catalogState.set(cacheKey, false);
|
||||
return false;
|
||||
}
|
||||
mergeCatalog(target, namespace, messages);
|
||||
catalogState.set(cacheKey, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// network error: leave state unset so a later attempt can retry
|
||||
console.warn(`L10N - Failed to load ${namespace} catalog for ${target}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCoreCatalog(target: string): Promise<boolean> {
|
||||
return loadCatalog(CORE_NAMESPACE, target);
|
||||
}
|
||||
|
||||
/** Load every registered namespace (core + modules) for a locale. */
|
||||
async function loadCatalogs(target: string): Promise<void> {
|
||||
await Promise.all(
|
||||
[...catalogProviders.keys()].map((namespace) => loadCatalog(namespace, target)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a namespace-specific catalog provider. The caller owns catalog
|
||||
* discovery and transport; this store owns locale orchestration and merging.
|
||||
*/
|
||||
async function registerCatalogProvider(namespace: string, provider: CatalogProvider): Promise<void> {
|
||||
catalogProviders.set(namespace, provider);
|
||||
for (const cacheKey of catalogState.keys()) {
|
||||
if (cacheKey.startsWith(`${namespace}:`)) catalogState.delete(cacheKey);
|
||||
}
|
||||
await loadCatalog(namespace, FALLBACK_LOCALE);
|
||||
if (locale.value !== FALLBACK_LOCALE) {
|
||||
await loadCatalog(namespace, locale.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(target: string): Promise<void> {
|
||||
await loadCatalogs(FALLBACK_LOCALE);
|
||||
if (target !== FALLBACK_LOCALE) {
|
||||
await loadCatalogs(target);
|
||||
}
|
||||
locale.value = target;
|
||||
applyLocale(target);
|
||||
document.documentElement.setAttribute('lang', target);
|
||||
document.documentElement.setAttribute('dir', isRtl.value ? 'rtl' : 'ltr');
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale candidates from browser preferences: each tag plus its base
|
||||
* language, in preference order ('de-AT' -> 'de-AT', 'de').
|
||||
*/
|
||||
function browserCandidates(): string[] {
|
||||
const candidates: string[] = [];
|
||||
for (const tag of navigator.languages ?? [navigator.language]) {
|
||||
if (!tag) continue;
|
||||
const base = tag.split('-')[0];
|
||||
if (!candidates.includes(tag)) candidates.push(tag);
|
||||
if (!candidates.includes(base)) candidates.push(base);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap from the /init l10n block (private shell), or from browser
|
||||
* preferences alone when there is no session (public shell).
|
||||
*/
|
||||
async function init(payload?: L10nInitPayload | null): Promise<void> {
|
||||
if (payload?.available?.length) {
|
||||
available.value = payload.available;
|
||||
}
|
||||
if (payload?.locale) {
|
||||
await activate(payload.locale);
|
||||
return;
|
||||
}
|
||||
// No resolved locale: probe browser candidates against served catalogs
|
||||
for (const candidate of browserCandidates()) {
|
||||
if (candidate === FALLBACK_LOCALE || (await loadCoreCatalog(candidate))) {
|
||||
await activate(candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await activate(FALLBACK_LOCALE);
|
||||
}
|
||||
|
||||
/** Switch locale; persists as the user's `core.locale` setting by default. */
|
||||
async function setLocale(target: string, persist = true): Promise<void> {
|
||||
await activate(target);
|
||||
if (persist) {
|
||||
useUserStore().setSetting(SETTING_KEY, target);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
available,
|
||||
isRtl,
|
||||
init,
|
||||
setLocale,
|
||||
registerCatalogProvider,
|
||||
};
|
||||
});
|
||||
@@ -13,6 +13,8 @@ export interface IntegrationItem {
|
||||
moduleHandle: string;
|
||||
priority?: number;
|
||||
label?: string;
|
||||
/** Localization key for the label (module-relative; prefixed with the handle on registration). `label` is its fallback. */
|
||||
l10n?: string;
|
||||
caption?: string;
|
||||
icon?: string;
|
||||
to?: string;
|
||||
@@ -30,6 +32,8 @@ export interface IntegrationGroup {
|
||||
moduleHandle: string;
|
||||
priority?: number;
|
||||
label?: string;
|
||||
/** Localization key for the label (module-relative; prefixed with the handle on registration). `label` is its fallback. */
|
||||
l10n?: string;
|
||||
caption?: string;
|
||||
icon?: string;
|
||||
style?: IntegrationGroupStyle;
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { App } from 'vue';
|
||||
import { router } from '@KTXC/router';
|
||||
import { useModuleStore } from '@KTXC/stores/moduleStore';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import { useL10nStore } from '@KTXC/stores/l10nStore';
|
||||
import type { CatalogMessages } from '@KTXC/stores/l10nStore';
|
||||
|
||||
function installModuleCSS(moduleHandle: string, cssPaths: string | string[]): void {
|
||||
const cssFiles = Array.isArray(cssPaths) ? cssPaths : [cssPaths];
|
||||
@@ -46,12 +48,20 @@ function installModuleRoutes(moduleHandle: string, routes: any[]): void {
|
||||
});
|
||||
}
|
||||
|
||||
function installModuleIntegrations(
|
||||
moduleHandle: string,
|
||||
integrations: Record<string, any[]>
|
||||
): void {
|
||||
const integrationStore = useIntegrationStore();
|
||||
integrationStore.registerModuleIntegrations(moduleHandle, integrations);
|
||||
function installModuleIntegrations(moduleHandle: string, integrations: Record<string, any[]>): void {
|
||||
useIntegrationStore().registerModuleIntegrations(moduleHandle, integrations);
|
||||
}
|
||||
|
||||
async function installModuleLocalizations(moduleHandle: string): Promise<void> {
|
||||
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> {
|
||||
@@ -72,11 +82,16 @@ export async function initializeModules(app: App): Promise<void> {
|
||||
console.log(`Module Loader - Loading ${moduleInfo.handle} from ${moduleUrl}`);
|
||||
|
||||
const loadPromise = import(/* @vite-ignore */ moduleUrl)
|
||||
.then((module) => {
|
||||
.then(async (module) => {
|
||||
// Load CSS if module explicitly exports css path(s)
|
||||
if (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
|
||||
console.log(`Module Loader - Installing ${moduleInfo.handle}`);
|
||||
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
|
||||
await Promise.all(loadPromises);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ import { RouterView } from 'vue-router';
|
||||
import LayoutHeader from '@KTXC/layouts/header/LayoutHeader.vue';
|
||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||
import LayoutSystemMenu from '@KTXC/layouts/menus/LayoutSystemMenu.vue';
|
||||
import { useL10nStore } from '@KTXC/stores/l10nStore';
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const l10nStore = useL10nStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-locale-provider>
|
||||
<v-locale-provider :rtl="l10nStore.isRtl">
|
||||
<v-app :class="[layoutStore.miniSidebar ? 'mini-sidebar' : '']">
|
||||
<LayoutHeader />
|
||||
<LayoutSystemMenu />
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import { RouterView } from 'vue-router';
|
||||
import LayoutFooter from '@KTXC/layouts/footer/LayoutFooter.vue';
|
||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||
import { useL10nStore } from '@KTXC/stores/l10nStore';
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const l10nStore = useL10nStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-locale-provider>
|
||||
<v-locale-provider :rtl="l10nStore.isRtl">
|
||||
<v-app :class="[layoutStore.miniSidebar ? 'mini-sidebar' : '']">
|
||||
<v-main class="page-wrapper">
|
||||
<v-container fluid>
|
||||
|
||||
+15
-1
@@ -14,5 +14,19 @@ export default [
|
||||
},
|
||||
pluginJs.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",
|
||||
"vite-plugin-vuetify": "^2.1.3",
|
||||
"vue": "^3.5.34",
|
||||
"vue-i18n": "^11.4.6",
|
||||
"vue-router": "^5.0.7",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuetify": "^4.0.7"
|
||||
@@ -640,6 +641,67 @@
|
||||
"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": {
|
||||
"version": "8.0.2",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "5.0.7",
|
||||
"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",
|
||||
"watch": "vite build --mode development --watch",
|
||||
"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",
|
||||
"test": "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",
|
||||
"vite-plugin-vuetify": "^2.1.3",
|
||||
"vue": "^3.5.34",
|
||||
"vue-i18n": "^11.4.6",
|
||||
"vue-router": "^5.0.7",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"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: '.',
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
{
|
||||
src: path.resolve(__dirname, 'core/src/l10n/*.json'),
|
||||
dest: 'l10n',
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user