chore: consolidate platform split checks
This commit is contained in:
382
webui/src/i18n/LanguageContext.tsx
Normal file
382
webui/src/i18n/LanguageContext.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
|
||||
import type { PlatformTranslationDictionary, PlatformTranslations } from "../types";
|
||||
import { generatedTranslations } from "./generatedTranslations";
|
||||
|
||||
export type PlatformLanguage = {
|
||||
code: string;
|
||||
label: string;
|
||||
nativeLabel?: string;
|
||||
};
|
||||
|
||||
export type PlatformLanguageContextValue = {
|
||||
availableLanguages: PlatformLanguage[];
|
||||
enabledLanguages: PlatformLanguage[];
|
||||
selectableLanguages: PlatformLanguage[];
|
||||
language: string;
|
||||
languageLabel: string;
|
||||
setLanguage: (code: string) => void;
|
||||
t: (key: string, fallback?: string) => string;
|
||||
translateText: (value: string) => string;
|
||||
};
|
||||
|
||||
type PlatformLanguageProviderProps = {
|
||||
children: ReactNode;
|
||||
systemAvailableLanguages?: PlatformLanguage[] | null;
|
||||
systemEnabledLanguageCodes?: string[] | null;
|
||||
userEnabledLanguageCodes?: string[] | null;
|
||||
defaultLanguage?: string | null;
|
||||
preferredLanguageCode?: string | null;
|
||||
onLanguageChange?: (code: string) => void;
|
||||
moduleTranslations?: Array<PlatformTranslations | null | undefined>;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "govoplan.language";
|
||||
const PRIMARY_LANGUAGE = "en";
|
||||
const I18N_MESSAGE_SEPARATOR = "::";
|
||||
const TRANSLATABLE_ATTRIBUTES = ["aria-label", "alt", "placeholder", "title"];
|
||||
const SKIP_TRANSLATION_SELECTOR = "script, style, code, pre, textarea, [data-i18n-skip]";
|
||||
const textNodeOriginals = new WeakMap<Text, string>();
|
||||
|
||||
export const DEFAULT_AVAILABLE_LANGUAGES: PlatformLanguage[] = [
|
||||
{ code: "en", label: "i18n:govoplan-core.english.649df08a", nativeLabel: "i18n:govoplan-core.language_native_english" },
|
||||
{ code: "de", label: "i18n:govoplan-core.german.da91388c", nativeLabel: "i18n:govoplan-core.language_native_german" }];
|
||||
|
||||
|
||||
export const DEFAULT_TRANSLATIONS: PlatformTranslations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
};
|
||||
|
||||
const PlatformLanguageContext = createContext<PlatformLanguageContextValue | null>(null);
|
||||
|
||||
export function PlatformLanguageProvider({
|
||||
children,
|
||||
systemAvailableLanguages,
|
||||
systemEnabledLanguageCodes,
|
||||
userEnabledLanguageCodes,
|
||||
defaultLanguage,
|
||||
preferredLanguageCode,
|
||||
onLanguageChange,
|
||||
moduleTranslations = []
|
||||
}: PlatformLanguageProviderProps) {
|
||||
const availableLanguages = useMemo(() => normalizeLanguages(systemAvailableLanguages), [systemAvailableLanguages]);
|
||||
const enabledLanguages = useMemo(
|
||||
() => languagesForCodes(availableLanguages, systemEnabledLanguageCodes),
|
||||
[availableLanguages, systemEnabledLanguageCodes]
|
||||
);
|
||||
const selectableLanguages = useMemo(() => {
|
||||
const selected = userEnabledLanguageCodes?.length ? languagesForCodes(enabledLanguages, userEnabledLanguageCodes) : enabledLanguages;
|
||||
return selected.length ? selected : enabledLanguages;
|
||||
}, [enabledLanguages, userEnabledLanguageCodes]);
|
||||
const fallbackLanguage = preferredLanguage(selectableLanguages, defaultLanguage);
|
||||
const [language, setLanguageState] = useState(() => preferredLanguage(selectableLanguages, preferredLanguageCode ?? storedLanguage() ?? defaultLanguage));
|
||||
|
||||
useEffect(() => {
|
||||
if (selectableLanguages.some((item) => item.code === language)) return;
|
||||
setLanguageState(fallbackLanguage);
|
||||
}, [fallbackLanguage, language, selectableLanguages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferredLanguageCode) return;
|
||||
const preferred = preferredLanguage(selectableLanguages, preferredLanguageCode);
|
||||
if (preferred) setLanguageState((current) => current === preferred ? current : preferred);
|
||||
}, [preferredLanguageCode, selectableLanguages]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
localStorage.setItem(STORAGE_KEY, language);
|
||||
}, [language]);
|
||||
|
||||
const translations = useMemo(() => mergeTranslations(DEFAULT_TRANSLATIONS, moduleTranslations), [moduleTranslations]);
|
||||
const dictionary = useMemo(
|
||||
() => ({ ...(translations[PRIMARY_LANGUAGE] ?? {}), ...(translations[language] ?? {}) }),
|
||||
[language, translations]
|
||||
);
|
||||
const translateText = useCallback((value: string) => translatePlainText(value, language, dictionary), [dictionary, language]);
|
||||
const t = useCallback((key: string, fallback = key) => {
|
||||
if (normalizeLanguageCode(language) === PRIMARY_LANGUAGE) return fallback;
|
||||
return dictionary[key] ?? translatePlainText(fallback, language, dictionary);
|
||||
}, [dictionary, language]);
|
||||
|
||||
useEffect(() => {
|
||||
return installDomTranslationBridge(language, dictionary);
|
||||
}, [dictionary, language]);
|
||||
|
||||
function setLanguage(code: string) {
|
||||
const normalized = normalizeLanguageCode(code);
|
||||
if (!selectableLanguages.some((item) => item.code === normalized)) return;
|
||||
setLanguageState(normalized);
|
||||
onLanguageChange?.(normalized);
|
||||
}
|
||||
|
||||
const languageLabel = selectableLanguages.find((item) => item.code === language)?.label ?? language.toUpperCase();
|
||||
const contextValue = useMemo(
|
||||
() => ({ availableLanguages, enabledLanguages, selectableLanguages, language, languageLabel, setLanguage, t, translateText }),
|
||||
[availableLanguages, enabledLanguages, language, languageLabel, selectableLanguages, t, translateText]
|
||||
);
|
||||
|
||||
return (
|
||||
<PlatformLanguageContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</PlatformLanguageContext.Provider>);
|
||||
|
||||
}
|
||||
|
||||
export function usePlatformLanguage(): PlatformLanguageContextValue {
|
||||
const value = useContext(PlatformLanguageContext);
|
||||
if (value) return value;
|
||||
const language = preferredLanguage(DEFAULT_AVAILABLE_LANGUAGES, null);
|
||||
return {
|
||||
availableLanguages: DEFAULT_AVAILABLE_LANGUAGES,
|
||||
enabledLanguages: DEFAULT_AVAILABLE_LANGUAGES,
|
||||
selectableLanguages: DEFAULT_AVAILABLE_LANGUAGES,
|
||||
language,
|
||||
languageLabel: DEFAULT_AVAILABLE_LANGUAGES.find((item) => item.code === language)?.label ?? language.toUpperCase(),
|
||||
setLanguage: () => undefined,
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
translateText: (value: string) => value
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLanguageCode(value?: string | null): string {
|
||||
const normalized = String(value || "").trim().toLowerCase().replace(/_/g, "-");
|
||||
const parts = normalized.split(/[^a-z0-9]+/).filter(Boolean);
|
||||
return parts.join("-") || "en";
|
||||
}
|
||||
|
||||
export function i18nMessage(key: string, values?: Record<string, unknown>): string {
|
||||
if (!values || Object.keys(values).length === 0) return key;
|
||||
return `${key}${I18N_MESSAGE_SEPARATOR}${encodeURIComponent(JSON.stringify(values))}`;
|
||||
}
|
||||
|
||||
export function translateReactNode(node: ReactNode, translateText: (value: string) => string): ReactNode {
|
||||
if (typeof node === "string") return translateText(node);
|
||||
if (node === null || node === undefined || typeof node === "boolean") return node;
|
||||
if (Array.isArray(node)) return Children.map(node, (child) => translateReactNode(child, translateText));
|
||||
if (isValidElement(node)) {
|
||||
const props = node.props as { children?: ReactNode };
|
||||
if (props.children === undefined) return node;
|
||||
return cloneElement(
|
||||
node as ReactElement<{ children?: ReactNode }>,
|
||||
undefined,
|
||||
translateReactNode(props.children, translateText)
|
||||
);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function normalizeLanguages(languages?: PlatformLanguage[] | null): PlatformLanguage[] {
|
||||
const source = languages?.length ? languages : DEFAULT_AVAILABLE_LANGUAGES;
|
||||
const seen = new Set<string>();
|
||||
return source.flatMap((language) => {
|
||||
const code = normalizeLanguageCode(language.code);
|
||||
if (seen.has(code)) return [];
|
||||
seen.add(code);
|
||||
return [{ ...language, code }];
|
||||
});
|
||||
}
|
||||
|
||||
function languagesForCodes(languages: PlatformLanguage[], codes?: string[] | null): PlatformLanguage[] {
|
||||
if (!codes?.length) return languages;
|
||||
const enabled = new Set(codes.map(normalizeLanguageCode));
|
||||
const enabledPrimary = new Set(Array.from(enabled).map(primaryLanguageCode));
|
||||
const filtered = languages.filter((language) => enabled.has(language.code) || enabledPrimary.has(primaryLanguageCode(language.code)));
|
||||
return filtered.length ? filtered : languages;
|
||||
}
|
||||
|
||||
function preferredLanguage(languages: PlatformLanguage[], preferred?: string | null): string {
|
||||
const codes = new Set(languages.map((item) => item.code));
|
||||
const requested = normalizeLanguageCode(preferred ?? storedLanguage() ?? browserLanguage());
|
||||
if (codes.has(requested)) return requested;
|
||||
const primary = primaryLanguageCode(requested);
|
||||
const primaryMatch = languages.find((item) => primaryLanguageCode(item.code) === primary);
|
||||
if (primaryMatch) return primaryMatch.code;
|
||||
if (codes.has(PRIMARY_LANGUAGE)) return PRIMARY_LANGUAGE;
|
||||
return languages[0]?.code ?? PRIMARY_LANGUAGE;
|
||||
}
|
||||
|
||||
function primaryLanguageCode(value: string): string {
|
||||
return normalizeLanguageCode(value).split("-", 1)[0];
|
||||
}
|
||||
|
||||
function storedLanguage(): string | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
function browserLanguage(): string | null {
|
||||
if (typeof navigator === "undefined") return null;
|
||||
return navigator.language;
|
||||
}
|
||||
|
||||
function mergeTranslations(base: PlatformTranslations, catalogs: Array<PlatformTranslations | null | undefined>): PlatformTranslations {
|
||||
const merged: PlatformTranslations = {};
|
||||
for (const source of [base, ...catalogs]) {
|
||||
if (!source) continue;
|
||||
for (const [language, dictionary] of Object.entries(source)) {
|
||||
const code = normalizeLanguageCode(language);
|
||||
merged[code] = { ...(merged[code] ?? {}), ...dictionary };
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function translatePlainText(value: string, language: string, dictionary: PlatformTranslationDictionary): string {
|
||||
const match = value.match(/^(\s*)(.*?)(\s*)$/s);
|
||||
if (!match) return value;
|
||||
const [, prefix, body, suffix] = match;
|
||||
if (!body) return value;
|
||||
if (body.startsWith("i18n:")) {
|
||||
const { key, values } = parseI18nMessage(body);
|
||||
if (Object.prototype.hasOwnProperty.call(dictionary, key)) {
|
||||
const translated = formatI18nMessage(dictionary[key] ?? key, values, dictionary);
|
||||
return `${prefix}${translated}${suffix}`;
|
||||
}
|
||||
}
|
||||
if (body.includes("i18n:")) {
|
||||
return `${prefix}${translateEmbeddedI18nMessages(body, dictionary)}${suffix}`;
|
||||
}
|
||||
if (normalizeLanguageCode(language) === PRIMARY_LANGUAGE) return value;
|
||||
const translated = dictionary[body] ?? body;
|
||||
return `${prefix}${translated}${suffix}`;
|
||||
}
|
||||
|
||||
function translateEmbeddedI18nMessages(value: string, dictionary: PlatformTranslationDictionary): string {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
const keys = Object.keys(dictionary).filter((key) => key.startsWith("i18n:")).sort((left, right) => right.length - left.length);
|
||||
|
||||
while (index < value.length) {
|
||||
const tokenStart = value.indexOf("i18n:", index);
|
||||
if (tokenStart < 0) {
|
||||
output += value.slice(index);
|
||||
break;
|
||||
}
|
||||
output += value.slice(index, tokenStart);
|
||||
const key = keys.find((candidate) => value.startsWith(candidate, tokenStart));
|
||||
if (!key) {
|
||||
output += "i18n:";
|
||||
index = tokenStart + "i18n:".length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let token = key;
|
||||
let nextIndex = tokenStart + key.length;
|
||||
if (value.startsWith(I18N_MESSAGE_SEPARATOR, nextIndex)) {
|
||||
const encodedStart = nextIndex + I18N_MESSAGE_SEPARATOR.length;
|
||||
let encodedEnd = encodedStart;
|
||||
while (encodedEnd < value.length && !/[\s<>"'`]/.test(value[encodedEnd])) encodedEnd += 1;
|
||||
token = value.slice(tokenStart, encodedEnd);
|
||||
nextIndex = encodedEnd;
|
||||
}
|
||||
const { values } = parseI18nMessage(token);
|
||||
output += formatI18nMessage(dictionary[key] ?? key, values, dictionary);
|
||||
index = nextIndex;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseI18nMessage(value: string): { key: string; values: Record<string, unknown> } {
|
||||
const [key, encodedValues] = value.split(I18N_MESSAGE_SEPARATOR, 2);
|
||||
if (!encodedValues) return { key, values: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(encodedValues));
|
||||
return { key, values: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {} };
|
||||
} catch {
|
||||
return { key, values: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function formatI18nMessage(template: string, values: Record<string, unknown>, dictionary: PlatformTranslationDictionary): string {
|
||||
return template.replace(/\{([A-Za-z0-9_]+)\}/g, (_match, name) => {
|
||||
const value = values[name];
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string" && value.startsWith("i18n:")) {
|
||||
const nested = parseI18nMessage(value);
|
||||
return formatI18nMessage(dictionary[nested.key] ?? nested.key, nested.values, dictionary);
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
function installDomTranslationBridge(language: string, dictionary: PlatformTranslationDictionary): () => void {
|
||||
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => undefined;
|
||||
const root = document.body ?? document.getElementById("root");
|
||||
if (!root) return () => undefined;
|
||||
let frame: number | null = null;
|
||||
|
||||
function run() {
|
||||
frame = null;
|
||||
translateDomSubtree(root, language, dictionary);
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (frame !== null) return;
|
||||
frame = window.requestAnimationFrame(run);
|
||||
}
|
||||
|
||||
schedule();
|
||||
|
||||
const observer = new MutationObserver(schedule);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: TRANSLATABLE_ATTRIBUTES,
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
translateDomSubtree(root, PRIMARY_LANGUAGE, dictionary);
|
||||
};
|
||||
}
|
||||
|
||||
function translateDomSubtree(root: Element, language: string, dictionary: PlatformTranslationDictionary): void {
|
||||
translateElementAttributes(root, language, dictionary);
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
||||
let current = walker.nextNode();
|
||||
while (current) {
|
||||
if (current.nodeType === Node.TEXT_NODE) {
|
||||
translateTextNode(current as Text, language, dictionary);
|
||||
} else if (current.nodeType === Node.ELEMENT_NODE) {
|
||||
translateElementAttributes(current as Element, language, dictionary);
|
||||
}
|
||||
current = walker.nextNode();
|
||||
}
|
||||
}
|
||||
|
||||
function translateTextNode(node: Text, language: string, dictionary: PlatformTranslationDictionary): void {
|
||||
const parent = node.parentElement;
|
||||
if (!parent || parent.closest(SKIP_TRANSLATION_SELECTOR)) return;
|
||||
const current = node.nodeValue ?? "";
|
||||
if (!current.trim()) return;
|
||||
|
||||
const stored = textNodeOriginals.get(node);
|
||||
const knownTranslation = stored ? translatePlainText(stored, language, dictionary) : null;
|
||||
const original = stored && (current === stored || current === knownTranslation) ? stored : current;
|
||||
textNodeOriginals.set(node, original);
|
||||
|
||||
const next = translatePlainText(original, language, dictionary);
|
||||
if (current !== next) node.nodeValue = next;
|
||||
}
|
||||
|
||||
function translateElementAttributes(element: Element, language: string, dictionary: PlatformTranslationDictionary): void {
|
||||
if (element.closest(SKIP_TRANSLATION_SELECTOR)) return;
|
||||
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
|
||||
const current = element.getAttribute(attribute);
|
||||
if (!current?.trim()) continue;
|
||||
|
||||
const storageAttribute = `data-i18n-original-${attribute}`;
|
||||
const stored = element.getAttribute(storageAttribute);
|
||||
const knownTranslation = stored ? translatePlainText(stored, language, dictionary) : null;
|
||||
const original = stored && (current === stored || current === knownTranslation) ? stored : current;
|
||||
element.setAttribute(storageAttribute, original);
|
||||
|
||||
const next = translatePlainText(original, language, dictionary);
|
||||
if (current !== next) element.setAttribute(attribute, next);
|
||||
}
|
||||
}
|
||||
1118
webui/src/i18n/generatedTranslations.ts
Normal file
1118
webui/src/i18n/generatedTranslations.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user