383 lines
15 KiB
TypeScript
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);
|
|
}
|
|
}
|