Make the i18n API more type safe

This commit is contained in:
Joseph Garrone 2024-09-22 17:14:03 +02:00
parent 8d2679b76e
commit e5ab46727a
7 changed files with 131 additions and 114 deletions

View File

@ -1,5 +1,4 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx"; import { clsx } from "keycloakify/tools/clsx";
import type { TemplateProps } from "keycloakify/login/TemplateProps"; import type { TemplateProps } from "keycloakify/login/TemplateProps";
import { getKcClsx } from "keycloakify/login/lib/kcClsx"; import { getKcClsx } from "keycloakify/login/lib/kcClsx";
@ -27,9 +26,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes }); 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(() => { useEffect(() => {
document.title = documentTitle ?? msgStr("loginTitle", kcContext.realm.displayName); document.title = documentTitle ?? msgStr("loginTitle", kcContext.realm.displayName);
@ -58,10 +57,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
{msg("loginTitleHtml", realm.displayNameHtml)} {msg("loginTitleHtml", realm.displayNameHtml)}
</div> </div>
</div> </div>
<div className={kcClsx("kcFormCardClass")}> <div className={kcClsx("kcFormCardClass")}>
<header className={kcClsx("kcFormHeaderClass")}> <header className={kcClsx("kcFormHeaderClass")}>
{realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && ( {enabledLanguages.length > 1 && (
<div className={kcClsx("kcLocaleMainClass")} id="kc-locale"> <div className={kcClsx("kcLocaleMainClass")} id="kc-locale">
<div id="kc-locale-wrapper" className={kcClsx("kcLocaleWrapperClass")}> <div id="kc-locale-wrapper" className={kcClsx("kcLocaleWrapperClass")}>
<div id="kc-locale-dropdown" className={clsx("menu-button-links", kcClsx("kcLocaleDropDownClass"))}> <div id="kc-locale-dropdown" className={clsx("menu-button-links", kcClsx("kcLocaleDropDownClass"))}>
@ -73,7 +71,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
aria-expanded="false" aria-expanded="false"
aria-controls="language-switch1" aria-controls="language-switch1"
> >
{labelBySupportedLanguageTag[currentLanguageTag]} {currentLanguage.label}
</button> </button>
<ul <ul
role="menu" role="menu"
@ -83,15 +81,10 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
id="language-switch1" id="language-switch1"
className={kcClsx("kcLocaleListClass")} className={kcClsx("kcLocaleListClass")}
> >
{locale.supported.map(({ languageTag }, i) => ( {enabledLanguages.map(({ languageTag, label, href }, i) => (
<li key={languageTag} className={kcClsx("kcLocaleListItemClass")} role="none"> <li key={languageTag} className={kcClsx("kcLocaleListItemClass")} role="none">
<a <a role="menuitem" id={`language-${i + 1}`} className={kcClsx("kcLocaleItemClass")} href={href}>
role="menuitem" {label}
id={`language-${i + 1}`}
className={kcClsx("kcLocaleItemClass")}
href={getChangeLocaleUrl(languageTag)}
>
{labelBySupportedLanguageTag[languageTag]}
</a> </a>
</li> </li>
))} ))}

View File

@ -10,9 +10,6 @@ export type KcContextLike = {
resourcesPath: string; resourcesPath: string;
ssoLoginInOtherTabsUrl: string; ssoLoginInOtherTabsUrl: string;
}; };
locale?: {
currentLanguageTag: string;
};
scripts: string[]; scripts: string[];
}; };
@ -25,19 +22,7 @@ export function useStylesAndScripts(params: {
}) { }) {
const { kcContext, doUseDefaultCss } = params; const { kcContext, doUseDefaultCss } = params;
const { url, locale, scripts } = kcContext; const { url, scripts } = kcContext;
useEffect(() => {
const { currentLanguageTag } = locale ?? {};
if (currentLanguageTag === undefined) {
return;
}
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
}, []);
const { areAllStyleSheetsLoaded } = useInsertLinkTags({ const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template", componentOrHookName: "Template",

View File

@ -1,8 +1,5 @@
export * from "./withJsx"; export * from "./withJsx";
import type { GenericI18n } from "./withJsx/GenericI18n"; import type { GenericI18n } from "./withJsx/GenericI18n";
import type { import type { MessageKey as MessageKey_defaultSet } from "./messages_defaultSet/types";
LanguageTag as LanguageTag_defaultSet,
MessageKey as MessageKey_defaultSet
} from "./messages_defaultSet/types";
/** INTERNAL: DO NOT IMPORT THIS */ /** INTERNAL: DO NOT IMPORT THIS */
export type I18n = GenericI18n<MessageKey_defaultSet, LanguageTag_defaultSet>; export type I18n = GenericI18n<MessageKey_defaultSet, string>;

View File

@ -1,22 +1,26 @@
export type GenericI18n_noJsx<MessageKey extends string, LanguageTag extends string> = { export type GenericI18n_noJsx<MessageKey extends string, LanguageTag extends string> = {
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" * Array of languages enabled on the realm.
*
* The current language
*/ */
currentLanguageTag: LanguageTag; enabledLanguages: {
/** languageTag: LanguageTag;
* Redirect to this url to change the language. label: string;
* After reload currentLanguageTag === newLanguageTag href: string;
*/ }[];
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<string /*LanguageTag*/, string>;
/** /**
* *
* Examples assuming currentLanguageTag === "en" * Examples assuming currentLanguageTag === "en"

View File

@ -90,64 +90,89 @@ export function createGetI18n<
return cachedResult; return cachedResult;
} }
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocaleUrl" | "labelBySupportedLanguageTag"> = { {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? (FALLBACK_LANGUAGE_TAG as any), const currentLanguageTag = kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG;
getChangeLocaleUrl: newLanguageTag => { const html = document.querySelector("html");
const { locale } = kcContext; 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<Exclude<LanguageTag, LanguageTag_defaultSet>>(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; from_server: {
}, if (kcContext.locale === undefined) {
labelBySupportedLanguageTag: (() => { break from_server;
const labelBySupportedLanguageTag = Object.fromEntries(
(kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])
);
// NOTE: For IE11
if (typeof Proxy === undefined) {
return labelBySupportedLanguageTag;
} }
// NOTE: This is for convenience in Storybook const supportedEntry = kcContext.locale.supported.find(entry => entry.languageTag === languageTag);
return new Proxy<Record<string, string>>(
{},
{
get: function (...args) {
const [, languageTag] = args;
if (typeof languageTag !== "string") { if (supportedEntry === undefined) {
return window.Reflect.get(...args); 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) { if (match !== null) {
assert(is<Exclude<LanguageTag, LanguageTag_defaultSet>>(languageTag)); return match[1];
}
const entry = extraLanguageTranslations[languageTag]; return supportedEntry.label;
}
assert(entry !== undefined); // NOTE: This should never happen
return languageTag;
label = entry.label;
}
return label;
}
}
);
})()
}; };
const currentLanguage: I18n["currentLanguage"] = (() => {
const languageTag = id<string>(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<string>(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<MessageKey_themeDefined>({ const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
themeName: kcContext.themeName, themeName: kcContext.themeName,
messages_themeDefined: messages_themeDefined:
messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ?? messagesByLanguageTag_themeDefined[currentLanguage.languageTag] ??
messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG as LanguageTag] ?? messagesByLanguageTag_themeDefined[id<string>(FALLBACK_LANGUAGE_TAG) as LanguageTag] ??
(() => { (() => {
const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0]; const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0];
if (firstLanguageTag === undefined) { if (firstLanguageTag === undefined) {
@ -158,11 +183,12 @@ export function createGetI18n<
messages_fromKcServer: kcContext["x-keycloakify"].messages messages_fromKcServer: kcContext["x-keycloakify"].messages
}); });
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === FALLBACK_LANGUAGE_TAG; const isCurrentLanguageFallbackLanguage = currentLanguage.languageTag === FALLBACK_LANGUAGE_TAG;
const result: Result = { const result: Result = {
i18n: { i18n: {
...partialI18n, currentLanguage,
enabledLanguages,
...createI18nTranslationFunctions({ ...createI18nTranslationFunctions({
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
}), }),
@ -172,7 +198,7 @@ export function createGetI18n<
? undefined ? undefined
: (async () => { : (async () => {
const messages_defaultSet_currentLanguage = await (async () => { const messages_defaultSet_currentLanguage = await (async () => {
const currentLanguageTag = partialI18n.currentLanguageTag; const currentLanguageTag = currentLanguage.languageTag;
const fromDefaultSet = await fetchMessages_defaultSet(currentLanguageTag); const fromDefaultSet = await fetchMessages_defaultSet(currentLanguageTag);
@ -198,7 +224,8 @@ export function createGetI18n<
})(); })();
const i18n_currentLanguage: I18n = { const i18n_currentLanguage: I18n = {
...partialI18n, currentLanguage,
enabledLanguages,
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }), ...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
isFetchingTranslations: false isFetchingTranslations: false
}; };

View File

@ -2,24 +2,28 @@ import type { GenericI18n_noJsx } from "../noJsx/GenericI18n_noJsx";
import { assert, type Equals } from "tsafe/assert"; import { assert, type Equals } from "tsafe/assert";
export type GenericI18n<MessageKey extends string, LanguageTag extends string> = { export type GenericI18n<MessageKey extends string, LanguageTag extends string> = {
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" * Array of languages enabled on the realm.
*
* The current language
*/ */
currentLanguageTag: LanguageTag; enabledLanguages: {
/** languageTag: LanguageTag;
* Redirect to this url to change the language. label: string;
* After reload currentLanguageTag === newLanguageTag href: string;
*/ }[];
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<string /*LanguageTag*/, string>;
/** /**
* *
* Examples assuming currentLanguageTag === "en" * Examples assuming currentLanguageTag === "en"

View File

@ -1,6 +1,7 @@
import { i18nBuilder } from "keycloakify/login/i18n"; import { i18nBuilder } from "keycloakify/login/i18n";
import { assert, type Equals } from "tsafe/assert"; import { assert, type Equals } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect"; import { Reflect } from "tsafe/Reflect";
import type { I18n as I18n_notExtended } from "keycloakify/login/i18n";
const { useI18n, ofTypeI18n } = i18nBuilder const { useI18n, ofTypeI18n } = i18nBuilder
.withThemeName<"my-theme-1" | "my-theme-2">() .withThemeName<"my-theme-1" | "my-theme-2">()
@ -36,10 +37,16 @@ type I18n = typeof ofTypeI18n;
assert<Equals<typeof i18n, I18n>>; assert<Equals<typeof i18n, I18n>>;
} }
{
const x = (_i18n: I18n_notExtended) => {};
x(Reflect<I18n>());
}
{ {
const i18n = Reflect<I18n>(); const i18n = Reflect<I18n>();
const got = i18n.currentLanguageTag; const got = i18n.currentLanguage.languageTag;
type Expected = type Expected =
| import("keycloakify/login/i18n/messages_defaultSet/types").LanguageTag | import("keycloakify/login/i18n/messages_defaultSet/types").LanguageTag