feat(i18n): auto-discover valid locales and harden language menu
- derive available locales from locale folders with required namespace validation - exclude incomplete locales and report missing namespace files - align system-language suggestion and selectors with discovered locales - improve launch HUD language menu interaction, scrolling, and viewport clipping - make i18n-check discover locale folders automatically
This commit is contained in:
+10
-4
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validates that all locale translation files have identical key structures.
|
||||
* Compares zh-CN and es against the en baseline for every namespace.
|
||||
* Compares all locale folders (except en) against the en baseline for every namespace.
|
||||
*
|
||||
* Usage: node scripts/i18n-check.mjs
|
||||
*/
|
||||
@@ -11,7 +11,6 @@ import path from "node:path";
|
||||
|
||||
const LOCALES_DIR = path.resolve("src/i18n/locales");
|
||||
const BASE_LOCALE = "en";
|
||||
const COMPARE_LOCALES = ["zh-CN", "es", "tr", "ko-KR"];
|
||||
|
||||
function getKeys(obj, prefix = "") {
|
||||
const keys = [];
|
||||
@@ -34,12 +33,19 @@ const namespaces = fs
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.replace(".json", ""));
|
||||
|
||||
const compareLocales = fs
|
||||
.readdirSync(LOCALES_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.filter((locale) => locale !== BASE_LOCALE)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
const basePath = path.join(baseDir, `${namespace}.json`);
|
||||
const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8"));
|
||||
const baseKeys = getKeys(baseData);
|
||||
|
||||
for (const locale of COMPARE_LOCALES) {
|
||||
for (const locale of compareLocales) {
|
||||
const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`);
|
||||
|
||||
if (!fs.existsSync(localePath)) {
|
||||
@@ -77,6 +83,6 @@ if (hasErrors) {
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(
|
||||
`i18n check PASSED — all ${COMPARE_LOCALES.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
|
||||
`i18n check PASSED — all ${compareLocales.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,3 +6,78 @@
|
||||
.electronNoDrag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.languageMenuScroll {
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.languageMenuScroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.languageMenuScroll::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.languageMenuScroll::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.2));
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.languageMenuScroll::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.3));
|
||||
}
|
||||
|
||||
.languageMenuContainer {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.languageMenuPanel {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 12rem;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98));
|
||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(14px);
|
||||
pointer-events: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.languageMenuItem {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.languageMenuItem:hover,
|
||||
.languageMenuItem:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.languageMenuItemActive {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Check, ChevronDown, Languages } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
|
||||
import { FaRegStopCircle } from "react-icons/fa";
|
||||
import { FaFolderOpen } from "react-icons/fa6";
|
||||
@@ -18,8 +19,7 @@ import {
|
||||
} from "react-icons/md";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||
import { SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import { getLocaleName } from "@/i18n/loader";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useCameraDevices } from "../../hooks/useCameraDevices";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
@@ -28,12 +28,6 @@ import { requestCameraAccess } from "../../lib/requestCameraAccess";
|
||||
import { formatTimePadded } from "../../utils/timeUtils";
|
||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { Tooltip } from "../ui/tooltip";
|
||||
import styles from "./LaunchWindow.module.css";
|
||||
|
||||
@@ -83,6 +77,7 @@ const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-cen
|
||||
|
||||
export function LaunchWindow() {
|
||||
const t = useScopedT("launch");
|
||||
const availableLocales = getAvailableLocales();
|
||||
const {
|
||||
locale,
|
||||
setLocale,
|
||||
@@ -123,6 +118,18 @@ export function LaunchWindow() {
|
||||
const [isWebcamHovered, setIsWebcamHovered] = useState(false);
|
||||
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
|
||||
const webcamExpanded = isWebcamHovered || isWebcamFocused;
|
||||
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
|
||||
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
|
||||
right: number;
|
||||
top: number;
|
||||
maxHeight: number;
|
||||
}>({
|
||||
right: 12,
|
||||
top: 12,
|
||||
maxHeight: 240,
|
||||
});
|
||||
|
||||
const {
|
||||
devices: micDevices,
|
||||
@@ -176,6 +183,71 @@ export function LaunchWindow() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLanguageMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
const clickedTrigger = languageTriggerRef.current?.contains(target);
|
||||
const clickedMenu = languageMenuPanelRef.current?.contains(target);
|
||||
if (!clickedTrigger && !clickedMenu) {
|
||||
setIsLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [isLanguageMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLanguageMenuOpen || !languageTriggerRef.current) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!languageTriggerRef.current) return;
|
||||
const rect = languageTriggerRef.current.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
const viewportPadding = 8;
|
||||
const availableHeight = Math.max(80, rect.top - viewportPadding - gap);
|
||||
const top = Math.max(viewportPadding, rect.top - gap - availableHeight);
|
||||
|
||||
setLanguageMenuStyle({
|
||||
right: Math.max(viewportPadding, window.innerWidth - rect.right),
|
||||
top,
|
||||
maxHeight: availableHeight,
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [isLanguageMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLanguageMenuOpen || !languageMenuPanelRef.current) return;
|
||||
const id = requestAnimationFrame(() => {
|
||||
if (languageMenuPanelRef.current) {
|
||||
languageMenuPanelRef.current.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [isLanguageMenuOpen]);
|
||||
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
|
||||
@@ -537,41 +609,60 @@ export function LaunchWindow() {
|
||||
|
||||
{/* Right sidebar controls */}
|
||||
<div className={`${hudSidebarClasses} ${styles.electronNoDrag}`}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("language")}
|
||||
className={`h-8 w-8 rounded-lg border border-white/10 bg-white/5 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Languages size={13} className="text-white/75" />
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
collisionPadding={6}
|
||||
className={`w-36 min-w-0 max-h-none overflow-y-hidden overflow-x-hidden border-white/15 bg-[rgba(24,24,34,0.98)] p-1 text-white shadow-2xl backdrop-blur-xl ${styles.electronNoDrag}`}
|
||||
<div className={`${styles.languageMenuContainer} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
ref={languageTriggerRef}
|
||||
type="button"
|
||||
aria-label={t("language")}
|
||||
aria-expanded={isLanguageMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setIsLanguageMenuOpen((open) => !open)}
|
||||
className={`h-8 w-8 rounded-lg border border-white/10 bg-white/5 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((loc) => (
|
||||
<DropdownMenuItem
|
||||
key={loc}
|
||||
onSelect={() => {
|
||||
setLocale(loc);
|
||||
resolveSystemLocaleSuggestion();
|
||||
}}
|
||||
className={`flex items-center justify-between rounded-sm px-2 py-1.5 text-[11px] transition-colors ${loc === locale ? "text-white" : "text-white/90"} focus:bg-white/10 focus:text-white ${styles.electronNoDrag}`}
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Languages size={13} className="text-white/75" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLanguageMenuOpen
|
||||
? createPortal(
|
||||
<div
|
||||
ref={languageMenuPanelRef}
|
||||
role="menu"
|
||||
className={`${styles.languageMenuPanel} ${styles.languageMenuScroll} ${styles.electronNoDrag}`}
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: "no-drag",
|
||||
pointerEvents: "auto",
|
||||
right: `${languageMenuStyle.right}px`,
|
||||
top: `${languageMenuStyle.top}px`,
|
||||
maxHeight: `${languageMenuStyle.maxHeight}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className="truncate">{getLocaleName(loc)}</span>
|
||||
{loc === locale ? <Check size={11} className="text-white/85" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{availableLocales.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={loc === locale}
|
||||
onClick={() => {
|
||||
setLocale(loc);
|
||||
resolveSystemLocaleSuggestion();
|
||||
setIsLanguageMenuOpen(false);
|
||||
}}
|
||||
className={`${styles.languageMenuItem} ${loc === locale ? styles.languageMenuItemActive : ""}`}
|
||||
>
|
||||
<span className="truncate">{getLocaleName(loc)}</span>
|
||||
{loc === locale ? <Check size={11} className="text-white/85" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
|
||||
@@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
portalled?: boolean;
|
||||
}
|
||||
>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => {
|
||||
const content = (
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
@@ -67,8 +69,14 @@ const DropdownMenuContent = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
);
|
||||
|
||||
if (!portalled) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <DropdownMenuPrimitive.Portal>{content}</DropdownMenuPrimitive.Portal>;
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
|
||||
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import { getLocaleName } from "@/i18n/loader";
|
||||
import { type Locale } from "@/i18n/config";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
import {
|
||||
calculateOutputDimensions,
|
||||
type ExportFormat,
|
||||
@@ -154,6 +154,7 @@ export default function VideoEditor() {
|
||||
const { shortcuts, isMac } = useShortcuts();
|
||||
const t = useScopedT("editor");
|
||||
const ts = useScopedT("settings");
|
||||
const availableLocales = getAvailableLocales();
|
||||
const { locale, setLocale } = useI18n();
|
||||
|
||||
const nextAnnotationIdRef = useRef(1);
|
||||
@@ -1707,7 +1708,7 @@ export default function VideoEditor() {
|
||||
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((loc) => (
|
||||
{availableLocales.map((loc) => (
|
||||
<option key={loc} value={loc} className="bg-[#09090b] text-white">
|
||||
{getLocaleName(loc)}
|
||||
</option>
|
||||
|
||||
@@ -8,14 +8,8 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
type I18nNamespace,
|
||||
LOCALE_STORAGE_KEY,
|
||||
type Locale,
|
||||
SUPPORTED_LOCALES,
|
||||
} from "@/i18n/config";
|
||||
import { translate } from "@/i18n/loader";
|
||||
import { DEFAULT_LOCALE, type I18nNamespace, LOCALE_STORAGE_KEY, type Locale } from "@/i18n/config";
|
||||
import { getAvailableLocales, translate } from "@/i18n/loader";
|
||||
|
||||
type TranslateVars = Record<string, string | number>;
|
||||
|
||||
@@ -48,11 +42,12 @@ export function useScopedT(namespace: I18nNamespace) {
|
||||
}
|
||||
|
||||
function isSupportedLocale(value: string): value is Locale {
|
||||
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
return getAvailableLocales().includes(value);
|
||||
}
|
||||
|
||||
function getSupportedSystemLocale(): Locale | null {
|
||||
if (typeof navigator === "undefined") return null;
|
||||
const availableLocales = getAvailableLocales();
|
||||
|
||||
const candidates =
|
||||
Array.isArray(navigator.languages) && navigator.languages.length > 0
|
||||
@@ -63,7 +58,7 @@ function getSupportedSystemLocale(): Locale | null {
|
||||
if (!candidate) continue;
|
||||
if (isSupportedLocale(candidate)) return candidate;
|
||||
|
||||
const exactMatch = SUPPORTED_LOCALES.find(
|
||||
const exactMatch = availableLocales.find(
|
||||
(locale) => locale.toLowerCase() === candidate.toLowerCase(),
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
@@ -71,9 +66,9 @@ function getSupportedSystemLocale(): Locale | null {
|
||||
const baseLanguage = candidate.split("-")[0]?.toLowerCase();
|
||||
if (!baseLanguage) continue;
|
||||
|
||||
if (baseLanguage === "zh") return "zh-CN";
|
||||
if (baseLanguage === "zh" && availableLocales.includes("zh-CN")) return "zh-CN";
|
||||
|
||||
const baseMatch = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase() === baseLanguage);
|
||||
const baseMatch = availableLocales.find((locale) => locale.toLowerCase() === baseLanguage);
|
||||
if (baseMatch) return baseMatch;
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
export const DEFAULT_LOCALE = "en" as const;
|
||||
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", "fr", "tr", "ko-KR"] as const;
|
||||
export const I18N_NAMESPACES = [
|
||||
"common",
|
||||
"dialogs",
|
||||
@@ -10,7 +9,7 @@ export const I18N_NAMESPACES = [
|
||||
"timeline",
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export type Locale = string;
|
||||
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
|
||||
|
||||
export const LOCALE_STORAGE_KEY = "openscreen-locale";
|
||||
|
||||
+71
-6
@@ -1,6 +1,10 @@
|
||||
import { DEFAULT_LOCALE, type I18nNamespace, type Locale } from "./config";
|
||||
import { DEFAULT_LOCALE, I18N_NAMESPACES, type I18nNamespace, type Locale } from "./config";
|
||||
|
||||
type MessageMap = Record<string, unknown>;
|
||||
type LocaleValidationError = {
|
||||
locale: string;
|
||||
missingNamespaces: I18nNamespace[];
|
||||
};
|
||||
|
||||
const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record<
|
||||
string,
|
||||
@@ -18,6 +22,62 @@ for (const [path, mod] of Object.entries(modules)) {
|
||||
messages[locale][namespace] = mod.default;
|
||||
}
|
||||
|
||||
const REQUIRED_NAMESPACES = new Set<string>(I18N_NAMESPACES);
|
||||
|
||||
const localeValidationErrors: LocaleValidationError[] = Object.keys(messages)
|
||||
.map((locale) => {
|
||||
const localeMessages = messages[locale] ?? {};
|
||||
const missingNamespaces = I18N_NAMESPACES.filter((namespace) => !localeMessages[namespace]);
|
||||
return {
|
||||
locale,
|
||||
missingNamespaces,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.missingNamespaces.length > 0);
|
||||
|
||||
const invalidLocales = new Set(localeValidationErrors.map((entry) => entry.locale));
|
||||
|
||||
const availableLocales = Object.keys(messages)
|
||||
.filter((locale) => REQUIRED_NAMESPACES.size > 0 && hasRequiredNamespaces(messages[locale]))
|
||||
.filter((locale) => !invalidLocales.has(locale))
|
||||
.sort((a, b) => {
|
||||
if (a === DEFAULT_LOCALE) return -1;
|
||||
if (b === DEFAULT_LOCALE) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
if (localeValidationErrors.length > 0) {
|
||||
console.error("[i18n] Incomplete locale folders were excluded:");
|
||||
for (const entry of localeValidationErrors) {
|
||||
console.error(
|
||||
`[i18n] ${entry.locale}: missing ${entry.missingNamespaces.map((ns) => `${ns}.json`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasRequiredNamespaces(localeMessages: Record<string, MessageMap> | undefined): boolean {
|
||||
if (!localeMessages) return false;
|
||||
for (const namespace of REQUIRED_NAMESPACES) {
|
||||
if (!localeMessages[namespace]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isAvailableLocale(locale: string): locale is Locale {
|
||||
return availableLocales.includes(locale);
|
||||
}
|
||||
|
||||
export function getAvailableLocales(): Locale[] {
|
||||
if (availableLocales.length === 0) {
|
||||
return [DEFAULT_LOCALE];
|
||||
}
|
||||
return availableLocales;
|
||||
}
|
||||
|
||||
export function getLocaleValidationErrors(): LocaleValidationError[] {
|
||||
return localeValidationErrors;
|
||||
}
|
||||
|
||||
function getMessageValue(obj: unknown, dotPath: string): string | undefined {
|
||||
const keys = dotPath.split(".");
|
||||
let current: unknown = obj;
|
||||
@@ -34,15 +94,18 @@ function interpolate(str: string, vars?: Record<string, string | number>): strin
|
||||
}
|
||||
|
||||
export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap {
|
||||
return messages[locale]?.[namespace] ?? {};
|
||||
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
return messages[resolvedLocale]?.[namespace] ?? {};
|
||||
}
|
||||
|
||||
export function getLocaleName(locale: Locale): string {
|
||||
return getMessageValue(messages[locale]?.common, "locale.name") ?? locale;
|
||||
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
return getMessageValue(messages[resolvedLocale]?.common, "locale.name") ?? locale;
|
||||
}
|
||||
|
||||
export function getLocaleShort(locale: Locale): string {
|
||||
return getMessageValue(messages[locale]?.common, "locale.short") ?? locale;
|
||||
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
return getMessageValue(messages[resolvedLocale]?.common, "locale.short") ?? locale;
|
||||
}
|
||||
|
||||
export function translate(
|
||||
@@ -52,8 +115,10 @@ export function translate(
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
const value =
|
||||
getMessageValue(messages[locale]?.[namespace], key) ??
|
||||
getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
|
||||
getMessageValue(
|
||||
messages[isAvailableLocale(locale) ? locale : DEFAULT_LOCALE]?.[namespace],
|
||||
key,
|
||||
) ?? getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
|
||||
|
||||
if (value == null) return `${namespace}.${key}`;
|
||||
return interpolate(value, vars);
|
||||
|
||||
Reference in New Issue
Block a user