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; }; 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(); 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(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 ( {children} ); } 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 { 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(); 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 { 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 } { 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, 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); } }