Make the i18n API more type safe
This commit is contained in:
parent
8d2679b76e
commit
e5ab46727a
@ -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>
|
||||||
))}
|
))}
|
||||||
|
@ -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",
|
||||||
|
@ -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>;
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user