Rework i18n

This commit is contained in:
Joseph Garrone
2024-07-13 09:07:11 +02:00
parent 9dca515a42
commit 4292c0c642
20 changed files with 508 additions and 514 deletions

View File

@ -0,0 +1,6 @@
import type { GenericI18n_noJsx } from "./i18n";
export type GenericI18n<MessageKey extends string> = GenericI18n_noJsx<MessageKey> & {
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
};

View File

@ -1,9 +1,10 @@
import "keycloakify/tools/Object.fromEntries";
import { assert } from "tsafe/assert";
import messages_fallbackLanguage from "./baseMessages/en";
import { getMessages } from "./baseMessages";
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";
import { id } from "tsafe/id";
export type KcContextLike = {
locale?: {
@ -17,9 +18,7 @@ export type KcContextLike = {
assert<KcContext extends KcContextLike ? true : false>();
export type MessageKey = keyof typeof messages_fallbackLanguage;
export type GenericI18n<MessageKey extends string> = {
export type GenericI18n_noJsx<MessageKey extends string> = {
/**
* e.g: "en", "fr", "zh-CN"
*
@ -39,16 +38,21 @@ export type GenericI18n<MessageKey extends string> = {
* */
labelBySupportedLanguageTag: Record<string, string>;
/**
* Examples assuming currentLanguageTag === "en"
*
* msg("access-denied") === <span>Access denied</span>
* msg("impersonateTitleHtml", "Foo") === <span><strong>Foo</strong> Impersonate User</span>
*/
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.
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
* 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;
/**
@ -59,24 +63,11 @@ export type GenericI18n<MessageKey extends string> = {
* {
* en: {
* "access-denied": "Access denied",
* "foo": "Foo {0} {1}",
* "bar": "Bar {0}"
* }
* }
*
* advancedMsg("${access-denied} foo bar") === <span>{msgStr("access-denied")} foo bar<span> === <span>Access denied foo bar</span>
* advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied") === <span>Access denied</span>
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === <span>not-a-message-key</span>
* advancedMsg("${bar}", "<strong>c</strong>")
* === <span>{msgStr("bar", "<strong>XXX</strong>")}<span>
* === <span>Bar &lt;strong&gt;XXX&lt;/strong&gt;</span> (The html in the arg is partially escaped for security reasons, it might be untrusted)
* advancedMsg("${foo} xx ${bar}", "a", "b", "c")
* === <span>{msgStr("foo", "a", "b")} xx {msgStr("bar")}<span>
* === <span>Foo a b xx Bar {0}</span> (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("${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;
@ -88,10 +79,12 @@ export type GenericI18n<MessageKey extends string> = {
isFetchingTranslations: boolean;
};
export function createGetI18n<ExtraMessageKey extends string = never>(messageBundle: {
[languageTag: string]: { [key in ExtraMessageKey]: string };
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 };
}) {
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
@ -126,11 +119,18 @@ export function createGetI18n<ExtraMessageKey extends string = never>(messageBun
labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]))
};
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey, ExtraMessageKey>({
messages_fallbackLanguage,
messageBundle_fallbackLanguage: messageBundle[fallbackLanguageTag],
messageBundle_currentLanguage: messageBundle[partialI18n.currentLanguageTag],
messageBundle_realm: kcContext["x-keycloakify"].messages
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;
@ -139,18 +139,18 @@ export function createGetI18n<ExtraMessageKey extends string = never>(messageBun
i18n: {
...partialI18n,
...createI18nTranslationFunctions({
messages_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_fallbackLanguage : undefined
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
}),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages_currentLanguage = await getMessages(partialI18n.currentLanguageTag);
const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag);
const i18n_currentLanguage: I18n = {
...partialI18n,
...createI18nTranslationFunctions({ messages_currentLanguage }),
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
isFetchingTranslations: false
};
@ -173,155 +173,72 @@ export function createGetI18n<ExtraMessageKey extends string = never>(messageBun
return { getI18n };
}
function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraMessageKey extends string>(params: {
messages_fallbackLanguage: Record<MessageKey, string>;
messageBundle_fallbackLanguage: Record<ExtraMessageKey, string> | undefined;
messageBundle_currentLanguage: Partial<Record<ExtraMessageKey, string>> | undefined;
messageBundle_realm: Record<string, string>;
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
messages_themeDefined: Record<MessageKey_themeDefined, string> | undefined;
messages_fromKcServer: Record<string, string>;
}) {
const { messageBundle_currentLanguage, messageBundle_realm } = params;
const messages_fallbackLanguage = {
...params.messages_fallbackLanguage,
...params.messageBundle_fallbackLanguage
};
const { messages_themeDefined, messages_fromKcServer } = params;
function createI18nTranslationFunctions(params: {
messages_currentLanguage: Partial<Record<MessageKey, string>> | undefined;
}): Pick<GenericI18n<MessageKey | ExtraMessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const messages_currentLanguage = {
...params.messages_currentLanguage,
...messageBundle_currentLanguage
};
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>, "msgStr" | "advancedMsgStr"> {
const { messages_defaultSet_currentLanguage } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
const { key, args, doRenderAsHtml } = props;
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
const { key, args } = props;
const messageOrUndefined: string | undefined = (() => {
terms_text: {
if (key !== "termsText") {
break terms_text;
}
const termsTextMessage = messageBundle_realm[key];
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];
if (termsTextMessage === undefined) {
break terms_text;
}
return termsTextMessage;
}
const messageOrUndefined = (messages_currentLanguage as any)[key] ?? (messages_fallbackLanguage as any)[key];
return messageOrUndefined;
})();
if (messageOrUndefined === undefined) {
if (message === undefined) {
return undefined;
}
const message = messageOrUndefined;
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
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;
}
if (startIndex === undefined) {
// No {0} in message (no arguments expected)
return message;
let messageWithArgsInjected = message;
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
let messageWithArgsInjected = message;
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
});
return messageWithArgsInjected;
})();
return doRenderAsHtml ? (
<span
// NOTE: The message is trusted. The arguments are not but are escaped.
dangerouslySetInnerHTML={{
__html: messageWithArgsInjectedIfAny
}}
/>
) : (
messageWithArgsInjectedIfAny
);
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
const { key, args, doRenderAsHtml } = props;
realm_messages: {
const resolvedMessage = messageBundle_realm[key] ?? messageBundle_realm["${" + key + "}"];
if (resolvedMessage === undefined) {
break realm_messages;
}
return doRenderAsHtml ? (
<span
// NOTE: The message is trusted. The arguments are not but are escaped.
dangerouslySetInnerHTML={{
__html: resolvedMessage
}}
/>
) : (
resolvedMessage
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
}
if (!/\$\{[^}]+\}/.test(key)) {
const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml });
if (resolvedMessage === undefined) {
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: key }} /> : 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 ? <span dangerouslySetInnerHTML={{ __html: resolvedComplexMessage }} /> : resolvedComplexMessage;
return messageWithArgsInjected;
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
const { key, args } = props;
const match = key.match(/^\$\{(.+)\}$/);
return resolveMsg({ key: match !== null ? match[1] : key, args }) ?? key;
}
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
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 })
};
}

View File

@ -1,4 +1,5 @@
import type { GenericI18n, MessageKey, KcContextLike } from "./i18n";
export type { MessageKey, KcContextLike };
export type I18n = GenericI18n<MessageKey>;
import type { GenericI18n } from "./GenericI18n";
import type { MessageKey_defaultSet, KcContextLike } from "./i18n";
export type { MessageKey_defaultSet, KcContextLike };
export type I18n = GenericI18n<MessageKey_defaultSet>;
export { createUseI18n } from "./useI18n";

View File

@ -1,44 +0,0 @@
import { useEffect, useState } from "react";
import {
createGetI18n,
type GenericI18n,
type MessageKey,
type KcContextLike
} from "./i18n";
import { Reflect } from "tsafe/Reflect";
export function createUseI18n<ExtraMessageKey extends string = never>(messageBundle: {
[languageTag: string]: { [key in ExtraMessageKey]: string };
}) {
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
const { getI18n } = createGetI18n(messageBundle);
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params;
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(i18n);
useEffect(() => {
let isActive = true;
prI18n_currentLanguage?.then(i18n => {
if (!isActive) {
return;
}
setI18n_toReturn(i18n);
});
return () => {
isActive = false;
};
}, []);
return { i18n: i18n_toReturn };
}
return { useI18n, ofTypeI18n: Reflect<I18n>() };
}

View File

@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n";
import { GenericI18n } from "./GenericI18n";
import { Reflect } from "tsafe/Reflect";
export function createUseI18n<MessageKey_themeDefined extends string = never>(messageBundle: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
type I18n = GenericI18n<MessageKey>;
const { withJsx } = (() => {
const cache = new WeakMap<GenericI18n_noJsx<MessageKey>, GenericI18n<MessageKey>>();
function renderHtmlString(htmlString: string): JSX.Element {
return (
<div
style={{ display: "inline-block" }}
dangerouslySetInnerHTML={{
__html: htmlString
}}
/>
);
/*
return (
<span
dangerouslySetInnerHTML={{
"__html": htmlString
}}
/>
);
*/
}
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey>): I18n {
use_cache: {
const i18n = cache.get(i18n_noJsx);
if (i18n === undefined) {
break use_cache;
}
return i18n;
}
const i18n: I18n = {
...i18n_noJsx,
msg: (...args) => renderHtmlString(i18n_noJsx.msgStr(...args)),
advancedMsg: (...args) => renderHtmlString(i18n_noJsx.advancedMsgStr(...args))
};
cache.set(i18n_noJsx, i18n);
return i18n;
}
return { withJsx };
})();
const { getI18n } = createGetI18n(messageBundle);
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params;
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(withJsx(i18n));
useEffect(() => {
let isActive = true;
prI18n_currentLanguage?.then(i18n => {
if (!isActive) {
return;
}
setI18n_toReturn(withJsx(i18n));
});
return () => {
isActive = false;
};
}, []);
return { i18n: i18n_toReturn };
}
return { useI18n, ofTypeI18n: Reflect<I18n>() };
}

View File

@ -9,7 +9,7 @@ import { formatNumber } from "keycloakify/tools/formatNumber";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import type { PasswordPolicies, Attribute, Validators } from "keycloakify/login/KcContext";
import type { KcContext } from "../KcContext";
import type { MessageKey } from "keycloakify/login/i18n";
import type { MessageKey_defaultSet } from "keycloakify/login/i18n";
import { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n";
import type { I18n } from "../i18n";
@ -148,7 +148,7 @@ export function useUserProfileForm(params: UseUserProfileFormParams): ReturnType
.map(name =>
id<Attribute>({
name: name,
displayName: id<`\${${MessageKey}}`>(`\${${name}}`),
displayName: id<`\${${MessageKey_defaultSet}}`>(`\${${name}}`),
required: true,
value: (kcContext.register as any).formData[name] ?? "",
html5DataAnnotations: {},
@ -176,7 +176,7 @@ export function useUserProfileForm(params: UseUserProfileFormParams): ReturnType
.map(name =>
id<Attribute>({
name: name,
displayName: id<`\${${MessageKey}}`>(`\${${name}}`),
displayName: id<`\${${MessageKey_defaultSet}}`>(`\${${name}}`),
required: true,
value: (kcContext as any).user[name] ?? "",
html5DataAnnotations: {},
@ -202,7 +202,7 @@ export function useUserProfileForm(params: UseUserProfileFormParams): ReturnType
return [
id<Attribute>({
name: "email",
displayName: id<`\${${MessageKey}}`>(`\${email}`),
displayName: id<`\${${MessageKey_defaultSet}}`>(`\${email}`),
required: true,
value: (kcContext.email as any).value ?? "",
html5DataAnnotations: {},
@ -293,7 +293,7 @@ export function useUserProfileForm(params: UseUserProfileFormParams): ReturnType
0,
{
name: "password",
displayName: id<`\${${MessageKey}}`>("${password}"),
displayName: id<`\${${MessageKey_defaultSet}}`>("${password}"),
required: true,
readOnly: false,
validators: {},
@ -303,7 +303,7 @@ export function useUserProfileForm(params: UseUserProfileFormParams): ReturnType
},
{
name: "password-confirm",
displayName: id<`\${${MessageKey}}`>("${passwordConfirm}"),
displayName: id<`\${${MessageKey_defaultSet}}`>("${passwordConfirm}"),
required: true,
readOnly: false,
validators: {},
@ -1134,7 +1134,7 @@ function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18
break validator_x;
}
const msgArgs = [errorMessageKey ?? id<MessageKey>("shouldMatchPattern"), pattern] as const;
const msgArgs = [errorMessageKey ?? id<MessageKey_defaultSet>("shouldMatchPattern"), pattern] as const;
errors.push({
errorMessage: <Fragment key={`${attributeName}-${errors.length}`}>{advancedMsg(...msgArgs)}</Fragment>,
@ -1173,7 +1173,7 @@ function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18
break validator_x;
}
const msgArgs = [id<MessageKey>("invalidEmailMessage")] as const;
const msgArgs = [id<MessageKey_defaultSet>("invalidEmailMessage")] as const;
errors.push({
errorMessage: <Fragment key={`${attributeName}-${errors.length}`}>{msg(...msgArgs)}</Fragment>,
@ -1265,11 +1265,11 @@ function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18
break validator_x;
}
const msgArgs = [id<MessageKey>("notAValidOption")] as const;
const msgArgs = [id<MessageKey_defaultSet>("notAValidOption")] as const;
errors.push({
errorMessage: <Fragment key={`${attributeName}-${errors.length}`}>{advancedMsg(...msgArgs)}</Fragment>,
errorMessageStr: advancedMsgStr(...msgArgs),
errorMessage: <Fragment key={`${attributeName}-${errors.length}`}>{msg(...msgArgs)}</Fragment>,
errorMessageStr: msgStr(...msgArgs),
fieldIndex: undefined,
source: {
type: "validator",

View File

@ -34,7 +34,7 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
if (requiredActions) {
html += "<b>";
html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(",");
html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(", ");
html += "</b>";
}