Files
govoplan-core/webui/src/i18n/LanguageContext.tsx
Albrecht Degering 635d25c74c
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled
chore: consolidate platform split checks
2026-07-10 12:51:19 +02:00

383 lines
15 KiB
TypeScript

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);
}
}