From a3050b3983d1bfd097eb1eb1e1e4c9e40f718b0f Mon Sep 17 00:00:00 2001 From: garronej Date: Sun, 31 Jul 2022 19:00:57 +0200 Subject: [PATCH] squash --- src/lib/i18n/createI18nApi.tsx | 219 +++++++++++++++++++++++++++++++++ src/lib/i18n/index.ts | 5 + 2 files changed, 224 insertions(+) create mode 100644 src/lib/i18n/createI18nApi.tsx create mode 100644 src/lib/i18n/index.ts diff --git a/src/lib/i18n/createI18nApi.tsx b/src/lib/i18n/createI18nApi.tsx new file mode 100644 index 00000000..9f36a2d5 --- /dev/null +++ b/src/lib/i18n/createI18nApi.tsx @@ -0,0 +1,219 @@ +import "minimal-polyfills/Object.fromEntries"; +//NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35 +import React, { createContext, useContext, useEffect, useState, memo } from "react"; +import type { ReactNode } from "react"; +import ReactMarkdown from "react-markdown"; +import type baseMessages from "./generated_kcMessages/18.0.1/login/en"; +import { assert } from "tsafe/assert"; +import type { KcContextBase } from "../getKcContext/KcContextBase"; + +const fallbackLanguageTag = "en"; + +export type BaseMessageKey = keyof typeof baseMessages | keyof typeof keycloakifyExtraMessages[typeof fallbackLanguageTag]; + +type I18n = { + msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string; + msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element; + /** advancedMsg("${access-denied}") === advancedMsg("access-denied") === msg("access-denied") */ + advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element; + /** advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === "not-a-message-key" */ + advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string; + currentLanguageTag: string; + changeLocale: (newLanguageTag: string) => never; + /** e.g. "en" => "English", "fr" => "Français" */ + labelBySupportedLanguageTag: Record; +}; + +type KcContextLike = { + locale?: { + currentLanguageTag: string; + supported: { languageTag: string; url: string; label: string }[]; + }; +}; + +assert(); + +export type I18nProviderProps = { + children: ReactNode; + fallback?: ReactNode; + kcContext: KcContextLike; +}; + +export function createI18nApi(params: { + extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string } }; +}) { + const { extraMessages } = params ?? {}; + + type MessageKey = ExtraMessageKey | BaseMessageKey; + + const context = createContext | undefined>(undefined); + + function useI18n(): I18n { + const i18n = useContext(context); + + assert(i18n !== undefined, "Now Wrapped in "); + + return i18n; + } + + const I18nProvider = memo((props: I18nProviderProps) => { + const { children, fallback, kcContext } = props; + + const [i18n, setI18n] = useState | undefined>(undefined); + + useEffect(() => { + let isMounted = true; + + (async () => { + const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {}; + + const [fallbackMessages, messages] = await Promise.all([ + import("./generated_kcMessages/18.0.1/login/en"), + import(`./generated_kcMessages/18.0.1/login/${currentLanguageTag}`), + ]); + + if (!isMounted) { + return; + } + + setI18n({ + ...createI18nTranslationFunctions({ + "fallbackMessages": { + ...fallbackMessages, + ...(keycloakifyExtraMessages[fallbackLanguageTag] ?? {}), + ...(extraMessages?.[fallbackLanguageTag] ?? {}), + } as any, + "messages": { + ...messages, + ...((keycloakifyExtraMessages as any)[currentLanguageTag] ?? {}), + ...(extraMessages?.[currentLanguageTag] ?? {}), + } as any, + }), + currentLanguageTag, + "changeLocale": 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`); + + window.location.href = targetSupportedLocale.url; + + assert(false, "never"); + }, + "labelBySupportedLanguageTag": Object.fromEntries( + (kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]), + ), + }); + })(); + + return () => { + isMounted = false; + }; + }, []); + + return {i18n === undefined ? fallback ?? null : children}; + }); + + return { useI18n, I18nProvider }; +} + +function createI18nTranslationFunctions(params: { + fallbackMessages: Record; + messages: Record; +}): Pick, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> { + const { fallbackMessages, messages } = params; + + function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): string | JSX.Element | undefined { + const { key, args, doRenderMarkdown } = props; + + const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key]; + + if (messageOrUndefined === undefined) { + return undefined; + } + + const message = messageOrUndefined; + + 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; + } + + let messageWithArgsInjected = message; + + args.forEach((arg, i) => { + if (arg === undefined) { + return; + } + + messageWithArgsInjected = messageWithArgsInjected.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg); + }); + + return messageWithArgsInjected; + })(); + + return doRenderMarkdown ? ( + + {messageWithArgsInjectedIfAny} + + ) : ( + messageWithArgsInjectedIfAny + ); + } + + function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): JSX.Element | string { + const { key, args, doRenderMarkdown } = props; + + const match = key.match(/^\$\{([^{]+)\}$/); + + const keyUnwrappedFromCurlyBraces = match === null ? key : match[1]; + + const out = resolveMsg({ + "key": keyUnwrappedFromCurlyBraces, + args, + doRenderMarkdown, + }); + + return (out !== undefined ? out : doRenderMarkdown ? {keyUnwrappedFromCurlyBraces} : keyUnwrappedFromCurlyBraces) as any; + } + + return { + "msgStr": (key, ...args) => resolveMsg({ key, args, "doRenderMarkdown": false }) as string, + "msg": (key, ...args) => resolveMsg({ key, args, "doRenderMarkdown": true }) as JSX.Element, + "advancedMsg": (key, ...args) => resolveMsgAdvanced({ key, args, "doRenderMarkdown": true }) as JSX.Element, + "advancedMsgStr": (key, ...args) => resolveMsgAdvanced({ key, args, "doRenderMarkdown": false }) as string, + }; +} + +const keycloakifyExtraMessages = { + "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": { + /* spell-checker: disable */ + "shouldBeEqual": "{0} doit être égal à {1}", + "shouldBeDifferent": "{0} doit être différent de {1}", + "shouldMatchPattern": "Dois respecter le schéma: `/{0}/`", + "mustBeAnInteger": "Doit être un nombre entier", + "notAValidOption": "N'est pas une option valide", + + "logoutConfirmTitle": "Déconnexion", + "logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?", + "doLogout": "Se déconnecter", + /* spell-checker: enable */ + }, +}; diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts new file mode 100644 index 00000000..88a3caaa --- /dev/null +++ b/src/lib/i18n/index.ts @@ -0,0 +1,5 @@ +import { createI18nApi } from "./createI18nApi"; + +export const { I18nProvider, useI18n } = createI18nApi({ + "extraMessages": {}, +});