i18n rebuild from the ground up

This commit is contained in:
garronej
2022-04-27 21:02:10 +02:00
parent cc8b2e72c1
commit 7e7071305f
26 changed files with 288 additions and 361 deletions

View File

@ -1,63 +0,0 @@
import { objectKeys } from "tsafe/objectKeys";
import { kcMessages } from "./kcMessages/login";
export type KcLanguageTag = keyof typeof kcMessages;
const kcLanguageByTagLabel = {
/* spell-checker: disable */
"es": "Español",
"it": "Italiano",
"fr": "Français",
"ca": "Català",
"en": "English",
"de": "Deutsch",
"no": "Norsk",
"pt-BR": "Português (Brasil)",
"ru": "Русский",
"sk": "Slovenčina",
"ja": "日本語",
"pl": "Polski",
"zh-CN": "中文简体",
"sv": "Svenska",
"lt": "Lietuvių",
"cs": "Čeština",
"nl": "Nederlands",
"tr": "Türkçe",
"da": "Dansk",
"hu": "Magyar",
/* spell-checker: enable */
} as const;
export type LanguageLabel = typeof kcLanguageByTagLabel[keyof typeof kcLanguageByTagLabel];
export function getKcLanguageTagLabel(language: KcLanguageTag): LanguageLabel {
return kcLanguageByTagLabel[language] ?? language;
}
export const kcLanguageTags = objectKeys(kcMessages);
/**
* Pass in "fr-FR" or "français" for example, it will return the AvailableLanguage
* it corresponds to: "fr".
* If there is no reasonable match it's guessed from navigator.language.
* If still no matches "en" is returned.
*/
export function getBestMatchAmongKcLanguageTag(languageLike: string): KcLanguageTag {
const iso2LanguageLike = languageLike.split("-")[0].toLowerCase();
const kcLanguageTag = kcLanguageTags.find(
language =>
language.toLowerCase().includes(iso2LanguageLike) ||
getKcLanguageTagLabel(language).toLocaleLowerCase() === languageLike.toLocaleLowerCase(),
);
if (kcLanguageTag !== undefined) {
return kcLanguageTag;
}
if (languageLike !== navigator.language) {
return getBestMatchAmongKcLanguageTag(navigator.language);
}
return "en";
}

180
src/lib/i18n/index.tsx Normal file
View File

@ -0,0 +1,180 @@
import "minimal-polyfills/Object.fromEntries";
//NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35
import ReactMarkdown from "react-markdown";
import memoize from "memoizee";
import { kcMessages as kcMessagesBase } from "./generated_kcMessages/15.0.2/login";
import { assert } from "tsafe/assert";
export const kcMessages = {
...kcMessagesBase,
"en": {
...kcMessagesBase["en"],
"termsText": "⏳",
"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",
},
"fr": {
...kcMessagesBase["fr"],
/* spell-checker: disable */
"shouldBeEqual": "{0} doit être egale à {1}",
"shouldBeDifferent": "{0} doit être différent de {1}",
"shouldMatchPattern": "Dois respecter le schéma: `/{0}/`",
"mustBeAnInteger": "Doit être un nombre entiers",
"notAValidOption": "N'est pas une option valide",
/* spell-checker: enable */
},
};
export type KcLanguageTag = keyof typeof kcMessages;
type KcContextLike = { locale?: { currentLanguageTag: KcLanguageTag } };
export function getCurrentKcLanguageTag(kcContext: KcContextLike) {
return kcContext.locale?.currentLanguageTag ?? "en";
}
export function getTagLabel(params: {
kcContext: {
locale?: {
supported: { languageTag: KcLanguageTag; label: string }[];
};
};
kcLanguageTag: KcLanguageTag;
}): string {
const { kcContext, kcLanguageTag } = params;
const { locale } = kcContext;
assert(locale !== undefined, "Internationalization not enabled");
const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === kcLanguageTag);
assert(targetSupportedLocale !== undefined, `${kcLanguageTag} need to be enabled in Keycloak admin`);
return targetSupportedLocale.label;
}
export function changeLocale(params: {
kcContext: {
locale?: {
supported: { languageTag: KcLanguageTag; url: string }[];
};
};
kcLanguageTag: KcLanguageTag;
}): never {
const { kcContext, kcLanguageTag } = params;
const { locale } = kcContext;
assert(locale !== undefined, "Internationalization not enabled");
const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === kcLanguageTag);
assert(targetSupportedLocale !== undefined, `${kcLanguageTag} need to be enabled in Keycloak admin`);
window.location.href = targetSupportedLocale.url;
assert(false, "never");
}
export type MessageKey = keyof typeof kcMessages["en"];
function resolveMsg<Key extends string, DoRenderMarkdown extends boolean>(props: {
key: Key;
args: (string | undefined)[];
kcLanguageTag: string;
doRenderMarkdown: DoRenderMarkdown;
}): Key extends MessageKey ? (DoRenderMarkdown extends true ? JSX.Element : string) : undefined {
const { key, args, kcLanguageTag, doRenderMarkdown } = props;
let str = kcMessages[kcLanguageTag as any as "en"][key as MessageKey] ?? kcMessages["en"][key as MessageKey];
if (str === undefined) {
return undefined as any;
}
str = (() => {
const startIndex = str
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
if (startIndex === undefined) {
return str;
}
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
str = str.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg);
});
return str;
})();
return (
doRenderMarkdown ? (
<ReactMarkdown allowDangerousHtml renderers={key === "termsText" ? undefined : { "paragraph": "span" }}>
{str}
</ReactMarkdown>
) : (
str
)
) as any;
}
function resolveMsgAdvanced<Key extends string, DoRenderMarkdown extends boolean>(props: {
key: Key;
args: (string | undefined)[];
kcLanguageTag: string;
doRenderMarkdown: DoRenderMarkdown;
}): DoRenderMarkdown extends true ? JSX.Element : string {
const { key, args, kcLanguageTag, doRenderMarkdown } = props;
const match = key.match(/^\$\{([^{]+)\}$/);
const keyUnwrappedFromCurlyBraces = match === null ? key : match[1];
const out = resolveMsg({
"key": keyUnwrappedFromCurlyBraces,
args,
kcLanguageTag,
doRenderMarkdown,
});
return (out !== undefined ? out : doRenderMarkdown ? <span>{keyUnwrappedFromCurlyBraces}</span> : keyUnwrappedFromCurlyBraces) as any;
}
/**
* When the language is switched the page is reloaded, this may appear
* as a bug as you might notice that the language successfully switch before
* reload.
* However we need to tell Keycloak that the user have changed the language
* during login so we can retrieve the "local" field of the JWT encoded accessToken.
* https://user-images.githubusercontent.com/6702424/138096682-351bb61f-f24e-4caf-91b7-cca8cfa2cb58.mov
*
* advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied")
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === "not-a-message-key"
*
*
* NOTE: This function is memoized, it always returns the same object for a given kcContext)
*
*/
export const getMsg = memoize((kcContext: KcContextLike) => {
const kcLanguageTag = getCurrentKcLanguageTag(kcContext);
return {
"msgStr": (key: MessageKey, ...args: (string | undefined)[]): string => resolveMsg({ key, args, kcLanguageTag, "doRenderMarkdown": false }),
"msg": (key: MessageKey, ...args: (string | undefined)[]): JSX.Element => resolveMsg({ key, args, kcLanguageTag, "doRenderMarkdown": true }),
"advancedMsg": <Key extends string>(key: Key, ...args: (string | undefined)[]): JSX.Element =>
resolveMsgAdvanced({ key, args, kcLanguageTag, "doRenderMarkdown": true }),
"advancedMsgStr": <Key extends string>(key: Key, ...args: (string | undefined)[]): string =>
resolveMsgAdvanced({ key, args, kcLanguageTag, "doRenderMarkdown": false }),
};
});

View File

@ -1,50 +0,0 @@
import { kcMessages as kcMessagesBase } from "../generated_kcMessages/15.0.2/login";
import { Evt } from "evt";
import { objectKeys } from "tsafe/objectKeys";
const kcMessages = {
...kcMessagesBase,
"en": {
...kcMessagesBase["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",
},
"fr": {
...kcMessagesBase["fr"],
/* spell-checker: disable */
"shouldBeEqual": "{0} doit être egale à {1}",
"shouldBeDifferent": "{0} doit être différent de {1}",
"shouldMatchPattern": "Dois respecter le schéma: `/{0}/`",
"mustBeAnInteger": "Doit être un nombre entiers",
"notAValidOption": "N'est pas une option valide",
/* spell-checker: enable */
},
};
export const evtTermsUpdated = Evt.asNonPostable(Evt.create<void>());
(["termsText", "doAccept", "doDecline", "termsTitle"] as const).forEach(key =>
objectKeys(kcMessages).forEach(kcLanguage =>
Object.defineProperty(
kcMessages[kcLanguage],
key,
(() => {
let value = key === "termsText" ? "⏳" : kcMessages[kcLanguage][key];
return {
"enumerable": true,
"get": () => value,
"set": (newValue: string) => {
value = newValue;
Evt.asPostable(evtTermsUpdated).post();
},
};
})(),
),
),
);
export { kcMessages };

View File

@ -1,28 +0,0 @@
import { createUseGlobalState } from "powerhooks/useGlobalState";
import { getKcContextFromWindow } from "../getKcContext/getKcContextFromWindow";
import { getBestMatchAmongKcLanguageTag } from "./KcLanguageTag";
import type { StatefulEvt } from "powerhooks";
import { KcLanguageTag } from "./KcLanguageTag";
//export const { useKcLanguageTag, evtKcLanguageTag } = createUseGlobalState(
const wrap = createUseGlobalState(
"kcLanguageTag",
() => {
const kcContext = getKcContextFromWindow();
const languageLike = kcContext?.locale?.current ?? (typeof navigator === "undefined" ? undefined : navigator.language);
if (languageLike === undefined) {
return "en";
}
return getBestMatchAmongKcLanguageTag(languageLike);
},
{ "persistance": "localStorage" },
);
export const { useKcLanguageTag } = wrap;
export function getEvtKcLanguage(): StatefulEvt<KcLanguageTag> {
return wrap.evtKcLanguageTag;
}

View File

@ -1,115 +0,0 @@
import "minimal-polyfills/Object.fromEntries";
import { useReducer } from "react";
import { useKcLanguageTag } from "./useKcLanguageTag";
import { kcMessages, evtTermsUpdated } from "./kcMessages/login";
import { useEvt } from "evt/hooks";
//NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35
import ReactMarkdown from "react-markdown";
import { useGuaranteedMemo } from "powerhooks/useGuaranteedMemo";
export { kcMessages };
export type MessageKey = keyof typeof kcMessages["en"];
function resolveMsg<Key extends string, DoRenderMarkdown extends boolean>(props: {
key: Key;
args: (string | undefined)[];
kcLanguageTag: string;
doRenderMarkdown: DoRenderMarkdown;
}): Key extends MessageKey ? (DoRenderMarkdown extends true ? JSX.Element : string) : undefined {
const { key, args, kcLanguageTag, doRenderMarkdown } = props;
let str = kcMessages[kcLanguageTag as any as "en"][key as MessageKey] ?? kcMessages["en"][key as MessageKey];
if (str === undefined) {
return undefined as any;
}
str = (() => {
const startIndex = str
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
if (startIndex === undefined) {
return str;
}
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
str = str.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg);
});
return str;
})();
return (
doRenderMarkdown ? (
<ReactMarkdown allowDangerousHtml renderers={key === "termsText" ? undefined : { "paragraph": "span" }}>
{str}
</ReactMarkdown>
) : (
str
)
) as any;
}
function resolveMsgAdvanced<Key extends string, DoRenderMarkdown extends boolean>(props: {
key: Key;
args: (string | undefined)[];
kcLanguageTag: string;
doRenderMarkdown: DoRenderMarkdown;
}): DoRenderMarkdown extends true ? JSX.Element : string {
const { key, args, kcLanguageTag, doRenderMarkdown } = props;
const match = key.match(/^\$\{([^{]+)\}$/);
const keyUnwrappedFromCurlyBraces = match === null ? key : match[1];
const out = resolveMsg({
"key": keyUnwrappedFromCurlyBraces,
args,
kcLanguageTag,
doRenderMarkdown,
});
return (out !== undefined ? out : doRenderMarkdown ? <span>{keyUnwrappedFromCurlyBraces}</span> : keyUnwrappedFromCurlyBraces) as any;
}
/**
* When the language is switched the page is reloaded, this may appear
* as a bug as you might notice that the language successfully switch before
* reload.
* However we need to tell Keycloak that the user have changed the language
* during login so we can retrieve the "local" field of the JWT encoded accessToken.
* https://user-images.githubusercontent.com/6702424/138096682-351bb61f-f24e-4caf-91b7-cca8cfa2cb58.mov
*
* advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied")
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === "not-a-message-key"
*
*/
export function useKcMessage() {
const { kcLanguageTag } = useKcLanguageTag();
const [trigger, forceUpdate] = useReducer((counter: number) => counter + 1, 0);
useEvt(ctx => evtTermsUpdated.attach(ctx, forceUpdate), []);
return useGuaranteedMemo(
() => ({
"msgStr": (key: MessageKey, ...args: (string | undefined)[]): string =>
resolveMsg({ key, args, kcLanguageTag, "doRenderMarkdown": false }),
"msg": (key: MessageKey, ...args: (string | undefined)[]): JSX.Element =>
resolveMsg({ key, args, kcLanguageTag, "doRenderMarkdown": true }),
"advancedMsg": <Key extends string>(key: Key, ...args: (string | undefined)[]): JSX.Element =>
resolveMsgAdvanced({ key, args, kcLanguageTag, "doRenderMarkdown": true }),
"advancedMsgStr": <Key extends string>(key: Key, ...args: (string | undefined)[]): string =>
resolveMsgAdvanced({ key, args, kcLanguageTag, "doRenderMarkdown": false }),
}),
[kcLanguageTag, trigger],
);
}