/** * @file Internationalization provider and hooks for the node editor */ import * as React from "react"; import type { Locale, I18nContextValue, I18nMessages, I18nKey, I18nDictionaries } from "./types"; import { enMessages } from "./en"; const I18nContext = React.createContext(null); I18nContext.displayName = "I18nContext"; type I18nProviderProps = { children: React.ReactNode; dictionaries: I18nDictionaries; initialLocale?: Locale; fallbackLocale?: Locale; /** * External message overrides or additions. You can provide partial maps per locale. * Example: { ja: { addNode: "ノード追加(外部)" } } */ messagesOverride?: Partial>>; }; const DEFAULT_FALLBACK_LOCALE: Locale = "en"; /** * Interpolates parameters into a message template using {{key}} syntax */ const interpolateParams = (message: string, params: Record): string => { return message.replace(/\{\{(\w+)\}\}/g, (match, key: string) => { const value = params[key]; return value !== undefined ? String(value) : match; }); }; const mergeDictionaries = ( dictionaries: I18nDictionaries, fallbackLocale: Locale, messagesOverride?: Partial>>, ): Record => { const merged: Record = {} as Record; (Object.entries(dictionaries) as Array<[Locale, I18nMessages]>).forEach(([locale, dictionary]) => { if (!dictionary) { return; } merged[locale] = { ...dictionary }; }); const fallbackDictionary = merged[fallbackLocale] ?? merged[DEFAULT_FALLBACK_LOCALE] ?? Object.values(merged)[0] ?? undefined; if (messagesOverride && fallbackDictionary) { (Object.entries(messagesOverride) as Array<[Locale, Partial]>).forEach( ([locale, overrideMessages]) => { if (!overrideMessages) { return; } const source = merged[locale] ?? fallbackDictionary; merged[locale] = { ...source, ...overrideMessages, } as I18nMessages; }, ); } return merged; }; export const I18nProvider: React.FC = ({ children, dictionaries, initialLocale, fallbackLocale = DEFAULT_FALLBACK_LOCALE, messagesOverride, }) => { const mergedDictionaries = React.useMemo( () => mergeDictionaries(dictionaries, fallbackLocale, messagesOverride), [dictionaries, fallbackLocale, messagesOverride], ); const availableLocales = React.useMemo(() => { return Object.keys(mergedDictionaries) as Locale[]; }, [mergedDictionaries]); const resolvedFallbackLocale: Locale | undefined = React.useMemo(() => { if (mergedDictionaries[fallbackLocale]) { return fallbackLocale; } if (mergedDictionaries[DEFAULT_FALLBACK_LOCALE]) { return DEFAULT_FALLBACK_LOCALE; } return availableLocales[0]; }, [availableLocales, fallbackLocale, mergedDictionaries]); if (!resolvedFallbackLocale) { throw new Error("I18nProvider requires at least one dictionary. Provide at least an English dictionary."); } const [locale, setLocaleState] = React.useState(() => { if (initialLocale && mergedDictionaries[initialLocale]) { return initialLocale; } return resolvedFallbackLocale; }); React.useEffect(() => { if (initialLocale && mergedDictionaries[initialLocale]) { setLocaleState(initialLocale); return; } if (!mergedDictionaries[locale]) { setLocaleState(resolvedFallbackLocale); } }, [initialLocale, locale, mergedDictionaries, resolvedFallbackLocale]); const setLocale = React.useCallback( (nextLocale: Locale) => { if (mergedDictionaries[nextLocale]) { setLocaleState(nextLocale); return; } setLocaleState(resolvedFallbackLocale); }, [mergedDictionaries, resolvedFallbackLocale], ); const translate = React.useCallback( (key: I18nKey, params?: Record): string => { const activeDictionary = mergedDictionaries[locale] ?? mergedDictionaries[resolvedFallbackLocale]; const fallbackDictionary = mergedDictionaries[resolvedFallbackLocale]; const message = activeDictionary?.[key] ?? fallbackDictionary?.[key] ?? key; return params ? interpolateParams(message, params) : message; }, [locale, mergedDictionaries, resolvedFallbackLocale], ); const contextValue: I18nContextValue = React.useMemo( () => ({ locale, setLocale, t: translate, availableLocales, }), [availableLocales, locale, setLocale, translate], ); return {children}; }; /** * Fallback translation function that uses English dictionary when I18nProvider is not available */ const fallbackTranslate = (key: I18nKey, params?: Record): string => { const message = enMessages[key] ?? key; return params ? interpolateParams(message, params) : message; }; const fallbackI18nValue: I18nContextValue = { locale: "en", setLocale: () => {}, t: fallbackTranslate, availableLocales: ["en"], }; export const useI18n = (): I18nContextValue => { const context = React.useContext(I18nContext); return context ?? fallbackI18nValue; }; // Hook for getting translated messages with fallback export const useTranslation = () => { const { t, locale, setLocale } = useI18n(); return React.useMemo( () => ({ t, locale, setLocale, // Additional helper functions formatNumber: (num: number): string => { return new Intl.NumberFormat(locale).format(num); }, formatDate: (date: Date): string => { return new Intl.DateTimeFormat(locale).format(date); }, formatDateTime: (date: Date): string => { return new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }).format(date); }, }), [t, locale, setLocale], ); };