import "keycloakify/tools/Object.fromEntries"; import { assert } from "tsafe/assert"; import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en"; import { fetchMessages_defaultSet } from "./messages_defaultSet"; import type { KcContext } from "../KcContext"; import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants"; import { id } from "tsafe/id"; export type KcContextLike = { locale?: { currentLanguageTag: string; supported: { languageTag: string; url: string; label: string }[]; }; "x-keycloakify": { messages: Record; }; }; assert(); export type GenericI18n_noJsx = { /** * e.g: "en", "fr", "zh-CN" * * The current language */ currentLanguageTag: string; /** * Redirect to this url to change the language. * After reload currentLanguageTag === newLanguageTag */ getChangeLocaleUrl: (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" * { * en: { * "access-denied": "Access denied", * "impersonateTitleHtml": "{0} Impersonate User", * "bar": "Bar {0}" * } * } * * msgStr("access-denied") === "Access denied" * msgStr("not-a-message-key") Throws an error * msgStr("impersonateTitleHtml", "Foo") === "Foo Impersonate User" * msgStr("${bar}", "c") === "Bar <strong>XXX</strong>" * The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html. */ 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", * } * } * * advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied" * advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key" */ 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; }; export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage; export function createGetI18n(messagesByLanguageTag_themeDefined: { [languageTag: string]: { [key in MessageKey_themeDefined]: string }; }) { type I18n = GenericI18n_noJsx; 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 ?? FALLBACK_LANGUAGE_TAG, getChangeLocaleUrl: 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_themeDefined: messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ?? messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG] ?? (() => { const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0]; if (firstLanguageTag === undefined) { return undefined; } return messagesByLanguageTag_themeDefined[firstLanguageTag]; })(), messages_fromKcServer: kcContext["x-keycloakify"].messages }); const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === FALLBACK_LANGUAGE_TAG; const result: Result = { i18n: { ...partialI18n, ...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined }), isFetchingTranslations: !isCurrentLanguageFallbackLanguage }, prI18n_currentLanguage: isCurrentLanguageFallbackLanguage ? undefined : (async () => { const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag); const i18n_currentLanguage: I18n = { ...partialI18n, ...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }), 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 }; } function createI18nTranslationFunctionsFactory(params: { messages_themeDefined: Record | undefined; messages_fromKcServer: Record; }) { const { messages_themeDefined, messages_fromKcServer } = params; function createI18nTranslationFunctions(params: { messages_defaultSet_currentLanguage: Partial> | undefined; }): Pick, "msgStr" | "advancedMsgStr"> { const { messages_defaultSet_currentLanguage } = params; function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined { const { key, args } = props; const message = id>(messages_fromKcServer)[key] ?? id | undefined>(messages_themeDefined)?.[key] ?? id | undefined>(messages_defaultSet_currentLanguage)?.[key] ?? id>(messages_defaultSet_fallbackLanguage)[key]; if (message === undefined) { return undefined; } 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; } function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string { const { key, args } = props; const match = key.match(/^\$\{(.+)\}$/); if (match === null) { return key; } return resolveMsg({ key: match[1], args }) ?? key; } return { msgStr: (key, ...args) => { const resolvedMessage = resolveMsg({ key, args }); assert(resolvedMessage !== undefined, `Message with key "${key}" not found`); return resolvedMessage; }, advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args }) }; } return { createI18nTranslationFunctions }; }