import "minimal-polyfills/Object.fromEntries"; //NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35 import { useEffect, useState, useRef } from "react"; import fallbackMessages from "./baseMessages/en"; import { getMessages } from "./baseMessages"; import { assert } from "tsafe/assert"; import type { KcContext } from "../kcContext/KcContext"; import { Markdown } from "keycloakify/tools/Markdown"; export const fallbackLanguageTag = "en"; export type KcContextLike = { locale?: { currentLanguageTag: string; supported: { languageTag: string; url: string; label: string }[]; }; }; assert(); export type MessageKey = keyof typeof fallbackMessages | keyof (typeof keycloakifyExtraMessages)[typeof fallbackLanguageTag]; export type GenericI18n = { /** * e.g: "en", "fr", "zh-CN" * * The current language */ currentLanguageTag: string; /** * To call when the user switch language. * This will cause the page to be reloaded, * on next load currentLanguageTag === newLanguageTag */ changeLocale: (newLanguageTag: string) => never; /** * 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; /** * Examples assuming currentLanguageTag === "en" * 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: (key: string, ...args: (string | undefined)[]) => JSX.Element; /** * Examples assuming currentLanguageTag === "en" * advancedMsg("${access-denied} foo bar") === msg("access-denied") + " foo bar" === "Access denied foo bar" * advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === "not-a-message-key" */ advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string; }; export type I18n = GenericI18n; export function createUseI18n(extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string }; }) { function useI18n(params: { kcContext: KcContextLike }): GenericI18n | null { const { kcContext } = params; const [i18n, setI18n] = useState | undefined>(undefined); const refHasStartedFetching = useRef(false); useEffect(() => { if (refHasStartedFetching.current) { return; } refHasStartedFetching.current = true; (async () => { const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {}; setI18n({ ...createI18nTranslationFunctions({ "fallbackMessages": { ...fallbackMessages, ...(keycloakifyExtraMessages[fallbackLanguageTag] ?? {}), ...(extraMessages[fallbackLanguageTag] ?? {}) } as any, "messages": { ...(await getMessages(currentLanguageTag)), ...((keycloakifyExtraMessages as any)[currentLanguageTag] ?? {}), ...(extraMessages[currentLanguageTag] ?? {}) } as any }), currentLanguageTag, "changeLocale": 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`); window.location.href = targetSupportedLocale.url; assert(false, "never"); }, "labelBySupportedLanguageTag": Object.fromEntries( (kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]) ) }); })(); }, []); return i18n ?? null; } return { useI18n }; } function createI18nTranslationFunctions(params: { fallbackMessages: Record; messages: Record; }): Pick, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> { const { fallbackMessages, messages } = params; function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): string | JSX.Element | undefined { const { key, args, doRenderMarkdown } = props; const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages 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); }); return messageWithArgsInjected; })(); return doRenderMarkdown ? ( {messageWithArgsInjectedIfAny} ) : ( messageWithArgsInjectedIfAny ); } function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): JSX.Element | string { const { key, args, doRenderMarkdown } = props; const match = key.match(/^\$\{([^{]+)\}$/); const keyUnwrappedFromCurlyBraces = match === null ? key : match[1]; const out = resolveMsg({ "key": keyUnwrappedFromCurlyBraces, args, doRenderMarkdown }); return (out !== undefined ? out : doRenderMarkdown ? {keyUnwrappedFromCurlyBraces} : keyUnwrappedFromCurlyBraces) as any; } return { "msgStr": (key, ...args) => resolveMsg({ key, args, "doRenderMarkdown": false }) as string, "msg": (key, ...args) => resolveMsg({ key, args, "doRenderMarkdown": true }) as JSX.Element, "advancedMsg": (key, ...args) => resolveMsgAdvanced({ key, args, "doRenderMarkdown": true }) as JSX.Element, "advancedMsgStr": (key, ...args) => resolveMsgAdvanced({ key, args, "doRenderMarkdown": false }) as string }; } const keycloakifyExtraMessages = { "en": { "shouldBeEqual": "{0} should be equal to {1}", "shouldBeDifferent": "{0} should be different to {1}", "shouldMatchPattern": "Pattern should match: `/{0}/`", "mustBeAnInteger": "Must be an integer", "notAValidOption": "Not a valid option", "newPasswordSameAsOld": "New password must be different from the old one", "passwordConfirmNotMatch": "Password confirmation does not match" }, "fr": { /* spell-checker: disable */ "shouldBeEqual": "{0} doit être égal à {1}", "shouldBeDifferent": "{0} doit être différent de {1}", "shouldMatchPattern": "Dois respecter le schéma: `/{0}/`", "mustBeAnInteger": "Doit être un nombre entier", "notAValidOption": "N'est pas une option valide", "logoutConfirmTitle": "Déconnexion", "logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?", "doLogout": "Se déconnecter", "newPasswordSameAsOld": "Le nouveau mot de passe doit être différent de l'ancien", "passwordConfirmNotMatch": "La confirmation du mot de passe ne correspond pas" /* spell-checker: enable */ } };