From e5ab46727a13f95423757dfe732ec4360d3afc9f Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 22 Sep 2024 17:14:03 +0200 Subject: [PATCH] Make the i18n API more type safe --- src/login/Template.tsx | 21 ++-- src/login/Template.useStylesAndScripts.ts | 17 +--- src/login/i18n/index.ts | 7 +- src/login/i18n/noJsx/GenericI18n_noJsx.ts | 36 ++++--- src/login/i18n/noJsx/getI18n.tsx | 119 +++++++++++++--------- src/login/i18n/withJsx/GenericI18n.tsx | 36 ++++--- test/login/i18n.typelevel-spec.ts | 9 +- 7 files changed, 131 insertions(+), 114 deletions(-) diff --git a/src/login/Template.tsx b/src/login/Template.tsx index ade06dee..ad4b194a 100644 --- a/src/login/Template.tsx +++ b/src/login/Template.tsx @@ -1,5 +1,4 @@ import { useEffect } from "react"; -import { assert } from "keycloakify/tools/assert"; import { clsx } from "keycloakify/tools/clsx"; import type { TemplateProps } from "keycloakify/login/TemplateProps"; import { getKcClsx } from "keycloakify/login/lib/kcClsx"; @@ -27,9 +26,9 @@ export default function Template(props: TemplateProps) { const { kcClsx } = getKcClsx({ doUseDefaultCss, classes }); - const { msg, msgStr, getChangeLocaleUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n; + const { msg, msgStr, currentLanguage, enabledLanguages } = i18n; - const { realm, locale, auth, url, message, isAppInitiatedAction } = kcContext; + const { realm, auth, url, message, isAppInitiatedAction } = kcContext; useEffect(() => { document.title = documentTitle ?? msgStr("loginTitle", kcContext.realm.displayName); @@ -58,10 +57,9 @@ export default function Template(props: TemplateProps) { {msg("loginTitleHtml", realm.displayNameHtml)} -
- {realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && ( + {enabledLanguages.length > 1 && (
@@ -73,7 +71,7 @@ export default function Template(props: TemplateProps) { aria-expanded="false" aria-controls="language-switch1" > - {labelBySupportedLanguageTag[currentLanguageTag]} + {currentLanguage.label}
    ) { id="language-switch1" className={kcClsx("kcLocaleListClass")} > - {locale.supported.map(({ languageTag }, i) => ( + {enabledLanguages.map(({ languageTag, label, href }, i) => (
  • - - {labelBySupportedLanguageTag[languageTag]} + + {label}
  • ))} diff --git a/src/login/Template.useStylesAndScripts.ts b/src/login/Template.useStylesAndScripts.ts index 72933100..a3d0b9a5 100644 --- a/src/login/Template.useStylesAndScripts.ts +++ b/src/login/Template.useStylesAndScripts.ts @@ -10,9 +10,6 @@ export type KcContextLike = { resourcesPath: string; ssoLoginInOtherTabsUrl: string; }; - locale?: { - currentLanguageTag: string; - }; scripts: string[]; }; @@ -25,19 +22,7 @@ export function useStylesAndScripts(params: { }) { const { kcContext, doUseDefaultCss } = params; - const { url, locale, scripts } = kcContext; - - useEffect(() => { - const { currentLanguageTag } = locale ?? {}; - - if (currentLanguageTag === undefined) { - return; - } - - const html = document.querySelector("html"); - assert(html !== null); - html.lang = currentLanguageTag; - }, []); + const { url, scripts } = kcContext; const { areAllStyleSheetsLoaded } = useInsertLinkTags({ componentOrHookName: "Template", diff --git a/src/login/i18n/index.ts b/src/login/i18n/index.ts index e37f77d5..64a6aff1 100644 --- a/src/login/i18n/index.ts +++ b/src/login/i18n/index.ts @@ -1,8 +1,5 @@ export * from "./withJsx"; import type { GenericI18n } from "./withJsx/GenericI18n"; -import type { - LanguageTag as LanguageTag_defaultSet, - MessageKey as MessageKey_defaultSet -} from "./messages_defaultSet/types"; +import type { MessageKey as MessageKey_defaultSet } from "./messages_defaultSet/types"; /** INTERNAL: DO NOT IMPORT THIS */ -export type I18n = GenericI18n; +export type I18n = GenericI18n; diff --git a/src/login/i18n/noJsx/GenericI18n_noJsx.ts b/src/login/i18n/noJsx/GenericI18n_noJsx.ts index 1a0afb30..22a68a20 100644 --- a/src/login/i18n/noJsx/GenericI18n_noJsx.ts +++ b/src/login/i18n/noJsx/GenericI18n_noJsx.ts @@ -1,22 +1,26 @@ export type GenericI18n_noJsx = { + currentLanguage: { + /** + * e.g: "en", "fr", "zh-CN" + * + * The current language + */ + languageTag: LanguageTag; + /** + * e.g: "English", "Français", "中文(简体)" + * + * The current language + */ + label: string; + }; /** - * e.g: "en", "fr", "zh-CN" - * - * The current language + * Array of languages enabled on the realm. */ - currentLanguageTag: LanguageTag; - /** - * Redirect to this url to change the language. - * After reload currentLanguageTag === newLanguageTag - */ - getChangeLocaleUrl: (newLanguageTag: string /*LanguageTag*/) => 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; + enabledLanguages: { + languageTag: LanguageTag; + label: string; + href: string; + }[]; /** * * Examples assuming currentLanguageTag === "en" diff --git a/src/login/i18n/noJsx/getI18n.tsx b/src/login/i18n/noJsx/getI18n.tsx index dee8a295..71cb676a 100644 --- a/src/login/i18n/noJsx/getI18n.tsx +++ b/src/login/i18n/noJsx/getI18n.tsx @@ -90,64 +90,89 @@ export function createGetI18n< return cachedResult; } - const partialI18n: Pick = { - currentLanguageTag: kcContext.locale?.currentLanguageTag ?? (FALLBACK_LANGUAGE_TAG as any), - getChangeLocaleUrl: newLanguageTag => { - const { locale } = kcContext; + { + const currentLanguageTag = kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG; + const html = document.querySelector("html"); + assert(html !== null); + html.lang = currentLanguageTag; + } - assert(locale !== undefined, "Internationalization not enabled"); + const getLanguageLabel = (languageTag: LanguageTag) => { + form_user_added_languages: { + if (!(languageTag in extraLanguageTranslations)) { + break form_user_added_languages; + } + assert(is>(languageTag)); - const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag); + const entry = extraLanguageTranslations[languageTag]; - assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`); + return entry.label; + } - return targetSupportedLocale.url; - }, - labelBySupportedLanguageTag: (() => { - const labelBySupportedLanguageTag = Object.fromEntries( - (kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]) - ); - - // NOTE: For IE11 - if (typeof Proxy === undefined) { - return labelBySupportedLanguageTag; + from_server: { + if (kcContext.locale === undefined) { + break from_server; } - // NOTE: This is for convenience in Storybook - return new Proxy>( - {}, - { - get: function (...args) { - const [, languageTag] = args; + const supportedEntry = kcContext.locale.supported.find(entry => entry.languageTag === languageTag); - if (typeof languageTag !== "string") { - return window.Reflect.get(...args); - } + if (supportedEntry === undefined) { + break from_server; + } - let label = labelBySupportedLanguageTag[languageTag]; + // cspell: disable-next-line + // from "Espagnol (Español)" we want to extract "Español" + const match = supportedEntry.label.match(/[^(]+\(([^)]+)\)/); - if (label === undefined || label === "" || label === languageTag) { - assert(is>(languageTag)); + if (match !== null) { + return match[1]; + } - const entry = extraLanguageTranslations[languageTag]; + return supportedEntry.label; + } - assert(entry !== undefined); - - label = entry.label; - } - - return label; - } - } - ); - })() + // NOTE: This should never happen + return languageTag; }; + const currentLanguage: I18n["currentLanguage"] = (() => { + const languageTag = id(kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG) as LanguageTag; + + return { + languageTag, + label: getLanguageLabel(languageTag) + }; + })(); + + const enabledLanguages: I18n["enabledLanguages"] = (() => { + const enabledLanguages: I18n["enabledLanguages"] = []; + + if (kcContext.locale !== undefined) { + for (const { languageTag, label, url } of kcContext.locale.supported ?? []) { + enabledLanguages.push({ + languageTag: id(languageTag) as LanguageTag, + label, + href: url + }); + } + } + + if (enabledLanguages.find(({ languageTag }) => languageTag === currentLanguage.languageTag) === undefined) { + enabledLanguages.push({ + languageTag: currentLanguage.languageTag, + label: getLanguageLabel(currentLanguage.languageTag), + href: "#" + }); + } + + return enabledLanguages; + })(); + const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory({ themeName: kcContext.themeName, messages_themeDefined: - messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ?? - messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG as LanguageTag] ?? + messagesByLanguageTag_themeDefined[currentLanguage.languageTag] ?? + messagesByLanguageTag_themeDefined[id(FALLBACK_LANGUAGE_TAG) as LanguageTag] ?? (() => { const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0]; if (firstLanguageTag === undefined) { @@ -158,11 +183,12 @@ export function createGetI18n< messages_fromKcServer: kcContext["x-keycloakify"].messages }); - const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === FALLBACK_LANGUAGE_TAG; + const isCurrentLanguageFallbackLanguage = currentLanguage.languageTag === FALLBACK_LANGUAGE_TAG; const result: Result = { i18n: { - ...partialI18n, + currentLanguage, + enabledLanguages, ...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined }), @@ -172,7 +198,7 @@ export function createGetI18n< ? undefined : (async () => { const messages_defaultSet_currentLanguage = await (async () => { - const currentLanguageTag = partialI18n.currentLanguageTag; + const currentLanguageTag = currentLanguage.languageTag; const fromDefaultSet = await fetchMessages_defaultSet(currentLanguageTag); @@ -198,7 +224,8 @@ export function createGetI18n< })(); const i18n_currentLanguage: I18n = { - ...partialI18n, + currentLanguage, + enabledLanguages, ...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }), isFetchingTranslations: false }; diff --git a/src/login/i18n/withJsx/GenericI18n.tsx b/src/login/i18n/withJsx/GenericI18n.tsx index f4a85e84..f669fe50 100644 --- a/src/login/i18n/withJsx/GenericI18n.tsx +++ b/src/login/i18n/withJsx/GenericI18n.tsx @@ -2,24 +2,28 @@ import type { GenericI18n_noJsx } from "../noJsx/GenericI18n_noJsx"; import { assert, type Equals } from "tsafe/assert"; export type GenericI18n = { + currentLanguage: { + /** + * e.g: "en", "fr", "zh-CN" + * + * The current language + */ + languageTag: LanguageTag; + /** + * e.g: "English", "Français", "中文(简体)" + * + * The current language + */ + label: string; + }; /** - * e.g: "en", "fr", "zh-CN" - * - * The current language + * Array of languages enabled on the realm. */ - currentLanguageTag: LanguageTag; - /** - * Redirect to this url to change the language. - * After reload currentLanguageTag === newLanguageTag - */ - getChangeLocaleUrl: (newLanguageTag: string /*LanguageTag*/) => 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; + enabledLanguages: { + languageTag: LanguageTag; + label: string; + href: string; + }[]; /** * * Examples assuming currentLanguageTag === "en" diff --git a/test/login/i18n.typelevel-spec.ts b/test/login/i18n.typelevel-spec.ts index d1338030..bfd0a5d5 100644 --- a/test/login/i18n.typelevel-spec.ts +++ b/test/login/i18n.typelevel-spec.ts @@ -1,6 +1,7 @@ import { i18nBuilder } from "keycloakify/login/i18n"; import { assert, type Equals } from "tsafe/assert"; import { Reflect } from "tsafe/Reflect"; +import type { I18n as I18n_notExtended } from "keycloakify/login/i18n"; const { useI18n, ofTypeI18n } = i18nBuilder .withThemeName<"my-theme-1" | "my-theme-2">() @@ -36,10 +37,16 @@ type I18n = typeof ofTypeI18n; assert>; } +{ + const x = (_i18n: I18n_notExtended) => {}; + + x(Reflect()); +} + { const i18n = Reflect(); - const got = i18n.currentLanguageTag; + const got = i18n.currentLanguage.languageTag; type Expected = | import("keycloakify/login/i18n/messages_defaultSet/types").LanguageTag