import "keycloakify/tools/Object.fromEntries"; import { useEffect, useState } from "react"; import { assert } from "tsafe/assert"; import messages_fallbackLanguage from "./baseMessages/en"; import { getMessages } from "./baseMessages"; import type { KcContext } from "../KcContext"; import { Reflect } from "tsafe/Reflect"; export const fallbackLanguageTag = "en"; export type KcContextLike = { locale?: { currentLanguageTag: string; supported: { languageTag: string; url: string; label: string }[]; }; }; assert(); export type MessageKey = keyof typeof messages_fallbackLanguage; export type GenericI18n = { /** * e.g: "en", "fr", "zh-CN" * * The current language */ currentLanguageTag: string; /** * Redirect to this url to change the language. * After reload currentLanguageTag === newLanguageTag */ getChangeLocalUrl: (newLanguageTag: string) => string; /** * e.g. "en" => "English", "fr" => "Français", ... * * Used to render a select that enable user to switch language. * ex: https://user-images.githubusercontent.com/6702424/186044799-38801eec-4e89-483b-81dd-8e9233e8c0eb.png * */ labelBySupportedLanguageTag: Record; /** * Examples assuming currentLanguageTag === "en" * * msg("access-denied") === Access denied * msg("impersonateTitleHtml", "Foo") === Foo Impersonate User */ msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element; /** * It's the same thing as msg() but instead of returning a JSX.Element it returns a string. * It can be more convenient to manipulate strings but if there are HTML tags it wont render. * msgStr("impersonateTitleHtml", "Foo") === "Foo Impersonate User" */ msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string; /** * This is meant to be used when the key argument is variable, something that might have been configured by the user * in the Keycloak admin for example. * * Examples assuming currentLanguageTag === "en" * { * en: { * "access-denied": "Access denied", * "foo": "Foo {0} {1}", * "bar": "Bar {0}" * } * } * * advancedMsg("${access-denied} foo bar") === {msgStr("access-denied")} foo bar === Access denied foo bar * advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied") === Access denied * advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === not-a-message-key * advancedMsg("${bar}", "c") * === {msgStr("bar", "XXX")} * === Bar <strong>XXX</strong> (The html in the arg is partially escaped for security reasons, it might be untrusted) * advancedMsg("${foo} xx ${bar}", "a", "b", "c") * === {msgStr("foo", "a", "b")} xx {msgStr("bar")} * === Foo a b xx Bar {0} (The substitution are only applied in the first message) */ advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element; /** * See advancedMsg() but instead of returning a JSX.Element it returns a string. */ advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string; /** * Initially the messages are in english (fallback language). * The translations in the current language are being fetched dynamically. * This property is true while the translations are being fetched. */ isFetchingTranslations: boolean; }; function createGetI18n(extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string } }) { type I18n = GenericI18n; type Result = { i18n: I18n; prI18n_currentLanguage: Promise | undefined }; const cachedResultByKcContext = new WeakMap(); function getI18n(params: { kcContext: KcContextLike }): Result { const { kcContext } = params; use_cache: { const cachedResult = cachedResultByKcContext.get(kcContext); if (cachedResult === undefined) { break use_cache; } return cachedResult; } const partialI18n: Pick = { currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag, getChangeLocalUrl: newLanguageTag => { const { locale } = kcContext; assert(locale !== undefined, "Internationalization not enabled"); const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag); assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`); return targetSupportedLocale.url; }, labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])) }; const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory({ messages_fallbackLanguage, extraMessages_fallbackLanguage: extraMessages[fallbackLanguageTag], extraMessages: extraMessages[partialI18n.currentLanguageTag] }); const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag !== fallbackLanguageTag; const result: Result = { i18n: { ...partialI18n, ...createI18nTranslationFunctions({ messages: undefined }), isFetchingTranslations: !isCurrentLanguageFallbackLanguage }, prI18n_currentLanguage: isCurrentLanguageFallbackLanguage ? undefined : (async () => { const messages = await getMessages(partialI18n.currentLanguageTag); const i18n_currentLanguage: I18n = { ...partialI18n, ...createI18nTranslationFunctions({ messages }), isFetchingTranslations: false }; // NOTE: This promise.resolve is just because without it we TypeScript // gives a Variable 'result' is used before being assigned. error await Promise.resolve().then(() => { result.i18n = i18n_currentLanguage; result.prI18n_currentLanguage = undefined; }); return i18n_currentLanguage; })() }; cachedResultByKcContext.set(kcContext, result); return result; } return { getI18n }; } export function createUseI18n(extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string }; }) { type I18n = GenericI18n; const { getI18n } = createGetI18n(extraMessages); function useI18n(params: { kcContext: KcContextLike }): I18n { const { kcContext } = params; const { i18n, prI18n_currentLanguage } = getI18n({ kcContext }); const [i18n_toReturn, setI18n_toReturn] = useState(i18n); useEffect(() => { let isActive = true; prI18n_currentLanguage?.then(i18n => { if (!isActive) { return; } setI18n_toReturn(i18n); }); return () => { isActive = false; }; }, []); return i18n_toReturn; } return { useI18n, ofTypeI18n: Reflect() }; } function createI18nTranslationFunctionsFactory(params: { messages_fallbackLanguage: Record; extraMessages_fallbackLanguage: Record | undefined; extraMessages: Partial> | undefined; }) { const { extraMessages } = params; const messages_fallbackLanguage = { ...params.messages_fallbackLanguage, ...params.extraMessages_fallbackLanguage }; function createI18nTranslationFunctions(params: { messages: Partial> | undefined; }): Pick, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> { const messages = { ...params.messages, ...extraMessages }; function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined { const { key, args, doRenderAsHtml } = props; const messageOrUndefined: string | undefined = (messages as any)[key] ?? (messages_fallbackLanguage as any)[key]; if (messageOrUndefined === undefined) { return undefined; } const message = messageOrUndefined; const messageWithArgsInjectedIfAny = (() => { const startIndex = message .match(/{[0-9]+}/g) ?.map(g => g.match(/{([0-9]+)}/)![1]) .map(indexStr => parseInt(indexStr)) .sort((a, b) => a - b)[0]; if (startIndex === undefined) { // No {0} in message (no arguments expected) return message; } let messageWithArgsInjected = message; args.forEach((arg, i) => { if (arg === undefined) { return; } messageWithArgsInjected = messageWithArgsInjected.replace( new RegExp(`\\{${i + startIndex}\\}`, "g"), arg.replace(//g, ">") ); }); return messageWithArgsInjected; })(); return doRenderAsHtml ? ( ) : ( messageWithArgsInjectedIfAny ); } function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string { const { key, args, doRenderAsHtml } = props; if (!/\$\{[^}]+\}/.test(key)) { const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml }); if (resolvedMessage === undefined) { return doRenderAsHtml ? : key; } return resolvedMessage; } let isFirstMatch = true; const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => { const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i; isFirstMatch = false; return replaceBy; }); return doRenderAsHtml ? : resolvedComplexMessage; } return { msgStr: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: false }) as string, msg: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: true }) as JSX.Element, advancedMsg: (key, ...args) => resolveMsgAdvanced({ key, args, doRenderAsHtml: true }) as JSX.Element, advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args, doRenderAsHtml: false }) as string }; } return { createI18nTranslationFunctions }; }