Refactor i18n so that we don't have to wait for translations to be downloaded to render the page
This commit is contained in:
@ -1,8 +1,10 @@
|
|||||||
import "keycloakify/tools/Object.fromEntries";
|
import "keycloakify/tools/Object.fromEntries";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { useConst } from "keycloakify/tools/useConst";
|
||||||
|
import { id } from "tsafe/id";
|
||||||
|
import { assert } from "tsafe/assert";
|
||||||
import fallbackMessages from "./baseMessages/en";
|
import fallbackMessages from "./baseMessages/en";
|
||||||
import { getMessages } from "./baseMessages";
|
import { getMessages } from "./baseMessages";
|
||||||
import { assert } from "tsafe/assert";
|
|
||||||
import type { KcContext } from "../KcContext";
|
import type { KcContext } from "../KcContext";
|
||||||
import { Reflect } from "tsafe/Reflect";
|
import { Reflect } from "tsafe/Reflect";
|
||||||
|
|
||||||
@ -86,159 +88,211 @@ export type I18n = GenericI18n<MessageKey>;
|
|||||||
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
|
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
|
||||||
[languageTag: string]: { [key in ExtraMessageKey]: string };
|
[languageTag: string]: { [key in ExtraMessageKey]: string };
|
||||||
}) {
|
}) {
|
||||||
function useI18n(params: { kcContext: KcContextLike }): GenericI18n<MessageKey | ExtraMessageKey> | null {
|
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
|
||||||
|
|
||||||
|
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n; isTranslationsDownloadOngoing: boolean } {
|
||||||
const { kcContext } = params;
|
const { kcContext } = params;
|
||||||
|
|
||||||
const [i18n, setI18n] = useState<GenericI18n<ExtraMessageKey | MessageKey> | undefined>(undefined);
|
const partialI18n = useMemo(
|
||||||
|
(): Pick<I18n, "currentLanguageTag" | "getChangeLocalUrl" | "labelBySupportedLanguageTag"> => ({
|
||||||
|
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag,
|
||||||
|
getChangeLocalUrl: newLanguageTag => {
|
||||||
|
const { locale } = kcContext;
|
||||||
|
|
||||||
const refHasStartedFetching = useRef(false);
|
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 } = useMemo(
|
||||||
|
() =>
|
||||||
|
createI18nTranslationFunctionsFactory<MessageKey, ExtraMessageKey>({
|
||||||
|
fallbackMessages,
|
||||||
|
extraFallbackMessages: extraMessages[fallbackLanguageTag],
|
||||||
|
extraMessages: extraMessages[partialI18n.currentLanguageTag]
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [i18n, setI18n] = useState<I18n | undefined>(undefined);
|
||||||
|
|
||||||
|
const refHasStartedFetching = useConst(() => ({ current: false }));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (refHasStartedFetching.current) {
|
if (refHasStartedFetching.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isActive = true;
|
||||||
|
|
||||||
refHasStartedFetching.current = true;
|
refHasStartedFetching.current = true;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {};
|
const messages = await getMessages(partialI18n.currentLanguageTag);
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setI18n({
|
setI18n({
|
||||||
...createI18nTranslationFunctions({
|
...partialI18n,
|
||||||
fallbackMessages: {
|
...createI18nTranslationFunctions({ messages })
|
||||||
...fallbackMessages,
|
|
||||||
...(extraMessages[fallbackLanguageTag] ?? {})
|
|
||||||
} as any,
|
|
||||||
messages: {
|
|
||||||
...(await getMessages(currentLanguageTag)),
|
|
||||||
...(extraMessages[currentLanguageTag] ?? {})
|
|
||||||
} as any
|
|
||||||
}),
|
|
||||||
currentLanguageTag,
|
|
||||||
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])
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return i18n ?? null;
|
const pendingI18n = useMemo(() => {
|
||||||
|
if (i18n !== undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return id<I18n>({
|
||||||
|
...partialI18n,
|
||||||
|
...createI18nTranslationFunctions({ messages: undefined })
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
i18n: i18n ?? (assert(pendingI18n !== undefined), pendingI18n),
|
||||||
|
isTranslationsDownloadOngoing: i18n === undefined
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
useI18n,
|
useI18n,
|
||||||
ofTypeI18n: Reflect<GenericI18n<MessageKey | ExtraMessageKey>>()
|
ofTypeI18n: Reflect<I18n>()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createI18nTranslationFunctions<MessageKey extends string>(params: {
|
/** Note exported only for hypothetical usage in non react framework */
|
||||||
|
export function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraMessageKey extends string>(params: {
|
||||||
fallbackMessages: Record<MessageKey, string>;
|
fallbackMessages: Record<MessageKey, string>;
|
||||||
messages: Record<MessageKey, string>;
|
extraFallbackMessages: Record<ExtraMessageKey, string> | undefined;
|
||||||
}): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
|
extraMessages: Partial<Record<ExtraMessageKey, string>> | undefined;
|
||||||
const { fallbackMessages, messages } = params;
|
}) {
|
||||||
|
const { extraMessages } = params;
|
||||||
|
|
||||||
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
|
const fallbackMessages = {
|
||||||
const { key, args, doRenderAsHtml } = props;
|
...params.fallbackMessages,
|
||||||
|
...params.extraFallbackMessages
|
||||||
|
};
|
||||||
|
|
||||||
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key];
|
function createI18nTranslationFunctions(params: {
|
||||||
|
messages: Partial<Record<MessageKey, string>> | undefined;
|
||||||
|
}): Pick<GenericI18n<MessageKey | ExtraMessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
|
||||||
|
const messages = {
|
||||||
|
...params.messages,
|
||||||
|
...extraMessages
|
||||||
|
};
|
||||||
|
|
||||||
if (messageOrUndefined === undefined) {
|
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
|
||||||
return undefined;
|
const { key, args, doRenderAsHtml } = props;
|
||||||
}
|
|
||||||
|
|
||||||
const message = messageOrUndefined;
|
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key];
|
||||||
|
|
||||||
const messageWithArgsInjectedIfAny = (() => {
|
if (messageOrUndefined === undefined) {
|
||||||
const startIndex = message
|
return undefined;
|
||||||
.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;
|
const message = messageOrUndefined;
|
||||||
|
|
||||||
args.forEach((arg, i) => {
|
const messageWithArgsInjectedIfAny = (() => {
|
||||||
if (arg === undefined) {
|
const startIndex = message
|
||||||
return;
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
messageWithArgsInjected = messageWithArgsInjected.replace(
|
let messageWithArgsInjected = message;
|
||||||
new RegExp(`\\{${i + startIndex}\\}`, "g"),
|
|
||||||
arg.replace(/</g, "<").replace(/>/g, ">")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return messageWithArgsInjected;
|
args.forEach((arg, i) => {
|
||||||
})();
|
if (arg === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return doRenderAsHtml ? (
|
messageWithArgsInjected = messageWithArgsInjected.replace(
|
||||||
<span
|
new RegExp(`\\{${i + startIndex}\\}`, "g"),
|
||||||
// NOTE: The message is trusted. The arguments are not but are escaped.
|
arg.replace(/</g, "<").replace(/>/g, ">")
|
||||||
dangerouslySetInnerHTML={{
|
);
|
||||||
__html: messageWithArgsInjectedIfAny
|
});
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
messageWithArgsInjectedIfAny
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
|
return messageWithArgsInjected;
|
||||||
const { key, args, doRenderAsHtml } = props;
|
})();
|
||||||
|
|
||||||
if (!/\$\{[^}]+\}/.test(key)) {
|
return doRenderAsHtml ? (
|
||||||
const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml });
|
<span
|
||||||
|
// NOTE: The message is trusted. The arguments are not but are escaped.
|
||||||
if (resolvedMessage === undefined) {
|
dangerouslySetInnerHTML={{
|
||||||
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: key }} /> : key;
|
__html: messageWithArgsInjectedIfAny
|
||||||
}
|
}}
|
||||||
|
/>
|
||||||
return resolvedMessage;
|
) : (
|
||||||
|
messageWithArgsInjectedIfAny
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let isFirstMatch = true;
|
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
|
||||||
|
const { key, args, doRenderAsHtml } = props;
|
||||||
|
|
||||||
const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => {
|
if (!/\$\{[^}]+\}/.test(key)) {
|
||||||
const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i;
|
const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml });
|
||||||
|
|
||||||
isFirstMatch = false;
|
if (resolvedMessage === undefined) {
|
||||||
|
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: key }} /> : key;
|
||||||
|
}
|
||||||
|
|
||||||
return replaceBy;
|
return resolvedMessage;
|
||||||
});
|
}
|
||||||
|
|
||||||
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: resolvedComplexMessage }} /> : resolvedComplexMessage;
|
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 {
|
||||||
|
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 {
|
return { createI18nTranslationFunctions };
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import "keycloakify/tools/Object.fromEntries";
|
import "keycloakify/tools/Object.fromEntries";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { useConst } from "keycloakify/tools/useConst";
|
||||||
|
import { id } from "tsafe/id";
|
||||||
import { assert } from "tsafe/assert";
|
import { assert } from "tsafe/assert";
|
||||||
import fallbackMessages from "./baseMessages/en";
|
import fallbackMessages from "./baseMessages/en";
|
||||||
import { getMessages } from "./baseMessages";
|
import { getMessages } from "./baseMessages";
|
||||||
@ -87,176 +89,228 @@ export type I18n = GenericI18n<MessageKey>;
|
|||||||
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
|
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
|
||||||
[languageTag: string]: { [key in ExtraMessageKey]: string };
|
[languageTag: string]: { [key in ExtraMessageKey]: string };
|
||||||
}) {
|
}) {
|
||||||
function useI18n(params: { kcContext: KcContextLike }): GenericI18n<MessageKey | ExtraMessageKey> | null {
|
type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
|
||||||
|
|
||||||
|
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n; isTranslationsDownloadOngoing: boolean } {
|
||||||
const { kcContext } = params;
|
const { kcContext } = params;
|
||||||
|
|
||||||
const [i18n, setI18n] = useState<GenericI18n<ExtraMessageKey | MessageKey> | undefined>(undefined);
|
const partialI18n = useMemo(
|
||||||
|
(): Pick<I18n, "currentLanguageTag" | "getChangeLocalUrl" | "labelBySupportedLanguageTag"> => ({
|
||||||
|
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag,
|
||||||
|
getChangeLocalUrl: newLanguageTag => {
|
||||||
|
const { locale } = kcContext;
|
||||||
|
|
||||||
const refHasStartedFetching = useRef(false);
|
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 } = useMemo(
|
||||||
|
() =>
|
||||||
|
createI18nTranslationFunctionsFactory<MessageKey, ExtraMessageKey>({
|
||||||
|
fallbackMessages,
|
||||||
|
extraFallbackMessages: extraMessages[fallbackLanguageTag],
|
||||||
|
extraMessages: extraMessages[partialI18n.currentLanguageTag],
|
||||||
|
__localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [i18n, setI18n] = useState<I18n | undefined>(undefined);
|
||||||
|
|
||||||
|
const refHasStartedFetching = useConst(() => ({ current: false }));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (refHasStartedFetching.current) {
|
if (refHasStartedFetching.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isActive = true;
|
||||||
|
|
||||||
refHasStartedFetching.current = true;
|
refHasStartedFetching.current = true;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {};
|
const messages = await getMessages(partialI18n.currentLanguageTag);
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setI18n({
|
setI18n({
|
||||||
...createI18nTranslationFunctions({
|
...partialI18n,
|
||||||
fallbackMessages: {
|
...createI18nTranslationFunctions({ messages })
|
||||||
...fallbackMessages,
|
|
||||||
...(extraMessages[fallbackLanguageTag] ?? {})
|
|
||||||
} as any,
|
|
||||||
messages: {
|
|
||||||
...(await getMessages(currentLanguageTag)),
|
|
||||||
...(extraMessages[currentLanguageTag] ?? {})
|
|
||||||
} as any,
|
|
||||||
__localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile
|
|
||||||
}),
|
|
||||||
currentLanguageTag,
|
|
||||||
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])
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return i18n ?? null;
|
const pendingI18n = useMemo(() => {
|
||||||
|
if (i18n !== undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return id<I18n>({
|
||||||
|
...partialI18n,
|
||||||
|
...createI18nTranslationFunctions({ messages: undefined })
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
i18n: i18n ?? (assert(pendingI18n !== undefined), pendingI18n),
|
||||||
|
isTranslationsDownloadOngoing: i18n === undefined
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
useI18n,
|
useI18n,
|
||||||
ofTypeI18n: Reflect<GenericI18n<MessageKey | ExtraMessageKey>>()
|
ofTypeI18n: Reflect<I18n>()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createI18nTranslationFunctions<MessageKey extends string>(params: {
|
/** Note exported only for hypothetical usage in non react framework */
|
||||||
|
export function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraMessageKey extends string>(params: {
|
||||||
fallbackMessages: Record<MessageKey, string>;
|
fallbackMessages: Record<MessageKey, string>;
|
||||||
messages: Record<MessageKey, string>;
|
extraFallbackMessages: Record<ExtraMessageKey, string> | undefined;
|
||||||
|
extraMessages: Partial<Record<ExtraMessageKey, string>> | undefined;
|
||||||
__localizationRealmOverridesUserProfile: Record<string, string> | undefined;
|
__localizationRealmOverridesUserProfile: Record<string, string> | undefined;
|
||||||
}): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
|
}) {
|
||||||
const { fallbackMessages, messages, __localizationRealmOverridesUserProfile } = params;
|
const { __localizationRealmOverridesUserProfile, extraMessages } = params;
|
||||||
|
|
||||||
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
|
const fallbackMessages = {
|
||||||
const { key, args, doRenderAsHtml } = props;
|
...params.fallbackMessages,
|
||||||
|
...params.extraFallbackMessages
|
||||||
|
};
|
||||||
|
|
||||||
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key];
|
function createI18nTranslationFunctions(params: {
|
||||||
|
messages: Partial<Record<MessageKey, string>> | undefined;
|
||||||
|
}): Pick<GenericI18n<MessageKey | ExtraMessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
|
||||||
|
const messages = {
|
||||||
|
...params.messages,
|
||||||
|
...extraMessages
|
||||||
|
};
|
||||||
|
|
||||||
if (messageOrUndefined === undefined) {
|
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
|
||||||
return undefined;
|
const { key, args, doRenderAsHtml } = props;
|
||||||
}
|
|
||||||
|
|
||||||
const message = messageOrUndefined;
|
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key];
|
||||||
|
|
||||||
const messageWithArgsInjectedIfAny = (() => {
|
if (messageOrUndefined === undefined) {
|
||||||
const startIndex = message
|
return undefined;
|
||||||
.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;
|
const message = messageOrUndefined;
|
||||||
|
|
||||||
args.forEach((arg, i) => {
|
const messageWithArgsInjectedIfAny = (() => {
|
||||||
if (arg === undefined) {
|
const startIndex = message
|
||||||
return;
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
messageWithArgsInjected = messageWithArgsInjected.replace(
|
let messageWithArgsInjected = message;
|
||||||
new RegExp(`\\{${i + startIndex}\\}`, "g"),
|
|
||||||
arg.replace(/</g, "<").replace(/>/g, ">")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return messageWithArgsInjected;
|
args.forEach((arg, i) => {
|
||||||
})();
|
if (arg === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return doRenderAsHtml ? (
|
messageWithArgsInjected = messageWithArgsInjected.replace(
|
||||||
<span
|
new RegExp(`\\{${i + startIndex}\\}`, "g"),
|
||||||
// NOTE: The message is trusted. The arguments are not but are escaped.
|
arg.replace(/</g, "<").replace(/>/g, ">")
|
||||||
dangerouslySetInnerHTML={{
|
);
|
||||||
__html: messageWithArgsInjectedIfAny
|
});
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
messageWithArgsInjectedIfAny
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
|
return messageWithArgsInjected;
|
||||||
const { key, args, doRenderAsHtml } = props;
|
})();
|
||||||
|
|
||||||
if (__localizationRealmOverridesUserProfile !== undefined && key in __localizationRealmOverridesUserProfile) {
|
|
||||||
const resolvedMessage = __localizationRealmOverridesUserProfile[key];
|
|
||||||
|
|
||||||
return doRenderAsHtml ? (
|
return doRenderAsHtml ? (
|
||||||
<span
|
<span
|
||||||
// NOTE: The message is trusted. The arguments are not but are escaped.
|
// NOTE: The message is trusted. The arguments are not but are escaped.
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: resolvedMessage
|
__html: messageWithArgsInjectedIfAny
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
resolvedMessage
|
messageWithArgsInjectedIfAny
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/\$\{[^}]+\}/.test(key)) {
|
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
|
||||||
const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml });
|
const { key, args, doRenderAsHtml } = props;
|
||||||
|
|
||||||
if (resolvedMessage === undefined) {
|
if (__localizationRealmOverridesUserProfile !== undefined && key in __localizationRealmOverridesUserProfile) {
|
||||||
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: key }} /> : key;
|
const resolvedMessage = __localizationRealmOverridesUserProfile[key];
|
||||||
|
|
||||||
|
return doRenderAsHtml ? (
|
||||||
|
<span
|
||||||
|
// NOTE: The message is trusted. The arguments are not but are escaped.
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: resolvedMessage
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
resolvedMessage
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolvedMessage;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
let isFirstMatch = true;
|
return {
|
||||||
|
msgStr: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: false }) as string,
|
||||||
const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => {
|
msg: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: true }) as JSX.Element,
|
||||||
const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i;
|
advancedMsg: (key, ...args) =>
|
||||||
|
resolveMsgAdvanced({
|
||||||
isFirstMatch = false;
|
key,
|
||||||
|
args,
|
||||||
return replaceBy;
|
doRenderAsHtml: true
|
||||||
});
|
}) as JSX.Element,
|
||||||
|
advancedMsgStr: (key, ...args) =>
|
||||||
return doRenderAsHtml ? <span dangerouslySetInnerHTML={{ __html: resolvedComplexMessage }} /> : resolvedComplexMessage;
|
resolveMsgAdvanced({
|
||||||
|
key,
|
||||||
|
args,
|
||||||
|
doRenderAsHtml: false
|
||||||
|
}) as string
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { createI18nTranslationFunctions };
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user