251 lines
9.9 KiB
TypeScript
Raw Normal View History

import "keycloakify/tools/Object.fromEntries";
import { assert } from "tsafe/assert";
2024-07-13 09:07:11 +02:00
import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en";
import { fetchMessages_defaultSet } from "./messages_defaultSet";
import type { KcContext } from "../KcContext";
import { fallbackLanguageTag } from "keycloakify/bin/shared/constants";
2024-07-13 09:07:11 +02:00
import { id } from "tsafe/id";
export type KcContextLike = {
locale?: {
currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[];
};
2024-07-13 09:07:11 +02:00
"x-keycloakify": {
messages: Record<string, string>;
};
};
assert<KcContext extends KcContextLike ? true : false>();
2024-07-13 09:07:11 +02:00
export type GenericI18n_noJsx<MessageKey extends string> = {
/**
* e.g: "en", "fr", "zh-CN"
*
* The current language
*/
currentLanguageTag: string;
/**
2024-05-14 02:32:03 +02:00
* Redirect to this url to change the language.
* After reload currentLanguageTag === newLanguageTag
*/
2024-06-21 20:28:14 +02:00
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<string, string>;
/**
2024-07-13 09:07:11 +02:00
*
* Examples assuming currentLanguageTag === "en"
2024-07-13 09:07:11 +02:00
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
2024-07-13 09:07:11 +02:00
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
2024-07-13 09:07:11 +02:00
* msgStr("${bar}", "<strong>c</strong>") === "Bar &lt;strong&gt;XXX&lt;/strong&gt;"
* 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;
/**
2024-05-27 23:44:41 +02:00
* 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"
2024-05-27 23:44:41 +02:00
* {
* en: {
* "access-denied": "Access denied",
* }
* }
*
2024-07-13 09:07:11 +02:00
* 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;
};
2024-07-13 09:07:11 +02:00
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
export function createGetI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag_themeDefined: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
2024-06-21 02:01:32 +02:00
}) {
2024-07-13 09:07:11 +02:00
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
function getI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
use_cache: {
const cachedResult = cachedResultByKcContext.get(kcContext);
if (cachedResult === undefined) {
break use_cache;
}
return cachedResult;
}
2024-06-21 20:28:14 +02:00
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocaleUrl" | "labelBySupportedLanguageTag"> = {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag,
2024-06-21 20:28:14 +02:00
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]))
};
2024-07-13 09:07:11 +02:00
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
messages_themeDefined:
messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ??
messagesByLanguageTag_themeDefined[fallbackLanguageTag] ??
(() => {
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 === fallbackLanguageTag;
const result: Result = {
i18n: {
...partialI18n,
2024-06-21 02:01:32 +02:00
...createI18nTranslationFunctions({
2024-07-13 09:07:11 +02:00
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
2024-06-21 02:01:32 +02:00
}),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
2024-07-13 09:07:11 +02:00
const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag);
const i18n_currentLanguage: I18n = {
...partialI18n,
2024-07-13 09:07:11 +02:00
...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 };
}
2024-07-13 09:07:11 +02:00
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
messages_themeDefined: Record<MessageKey_themeDefined, string> | undefined;
messages_fromKcServer: Record<string, string>;
}) {
2024-07-13 09:07:11 +02:00
const { messages_themeDefined, messages_fromKcServer } = params;
function createI18nTranslationFunctions(params: {
2024-07-13 09:07:11 +02:00
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>, "msgStr" | "advancedMsgStr"> {
const { messages_defaultSet_currentLanguage } = params;
2024-07-13 09:07:11 +02:00
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
const { key, args } = props;
2024-07-13 09:07:11 +02:00
const message =
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
id<Record<string, string | undefined> | undefined>(messages_themeDefined)?.[key] ??
id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];
2024-07-13 09:07:11 +02:00
if (message === undefined) {
return undefined;
}
2024-07-13 09:07:11 +02:00
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
2024-07-13 09:07:11 +02:00
if (startIndex === undefined) {
// No {0} in message (no arguments expected)
return message;
}
2024-07-13 09:07:11 +02:00
let messageWithArgsInjected = message;
2024-07-13 09:07:11 +02:00
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
2024-07-13 09:07:11 +02:00
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
});
2024-07-13 09:07:11 +02:00
return messageWithArgsInjected;
}
2024-07-13 09:07:11 +02:00
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
const { key, args } = props;
2024-07-13 09:07:11 +02:00
const match = key.match(/^\$\{(.+)\}$/);
2024-07-13 09:07:11 +02:00
if (match === null) {
return key;
2024-05-27 23:44:41 +02:00
}
2024-07-13 09:07:11 +02:00
return resolveMsg({ key: match[1], args }) ?? key;
}
return {
2024-07-13 09:07:11 +02:00
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 };
}