diff --git a/src/login/i18n/GenericI18n.tsx b/src/login/i18n/GenericI18n.tsx index a640ece0..7badb61e 100644 --- a/src/login/i18n/GenericI18n.tsx +++ b/src/login/i18n/GenericI18n.tsx @@ -1,6 +1,6 @@ import type { GenericI18n_noJsx } from "./i18n"; -export type GenericI18n = GenericI18n_noJsx & { +export type GenericI18n = GenericI18n_noJsx & { msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element; advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element; }; diff --git a/src/login/i18n/i18n.tsx b/src/login/i18n/i18n.tsx index 94b738c4..fec62c33 100644 --- a/src/login/i18n/i18n.tsx +++ b/src/login/i18n/i18n.tsx @@ -5,8 +5,12 @@ import { fetchMessages_defaultSet } from "./messages_defaultSet"; import type { KcContext } from "../KcContext"; import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants"; import { id } from "tsafe/id"; +import { is } from "tsafe/is"; +import { Reflect } from "tsafe/Reflect"; +import type { LanguageTag as LanguageTag_defaultSet } from "keycloakify/login/i18n/messages_defaultSet/LanguageTag"; export type KcContextLike = { + themeName: string; locale?: { currentLanguageTag: string; supported: { languageTag: string; url: string; label: string }[]; @@ -18,18 +22,18 @@ export type KcContextLike = { assert(); -export type GenericI18n_noJsx = { +export type GenericI18n_noJsx = { /** * e.g: "en", "fr", "zh-CN" * * The current language */ - currentLanguageTag: string; + currentLanguageTag: LanguageTag; /** * Redirect to this url to change the language. * After reload currentLanguageTag === newLanguageTag */ - getChangeLocaleUrl: (newLanguageTag: string) => string; + getChangeLocaleUrl: (newLanguageTag: LanguageTag) => string; /** * e.g. "en" => "English", "fr" => "Français", ... * @@ -81,10 +85,35 @@ export type GenericI18n_noJsx = { export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage; -export function createGetI18n(messagesByLanguageTag_themeDefined: { - [languageTag: string]: { [key in MessageKey_themeDefined]: string }; -}) { - type I18n = GenericI18n_noJsx; +export type ReturnTypeOfCreateGetI18n = { + getI18n: (params: { kcContext: KcContextLike }) => { + i18n: GenericI18n_noJsx; + prI18n_currentLanguage: + | Promise> + | undefined; + }; + ofTypeI18n: GenericI18n_noJsx; +}; + +export function createGetI18n< + ThemeName extends string = string, + MessageKey_themeDefined extends string = never, + LanguageTag_notInDefaultSet extends string = never +>(params: { + extraLanguageTranslations: { + [languageTag in LanguageTag_notInDefaultSet]: () => Promise<{ default: Record }>; + }; + messagesByLanguageTag_themeDefined: Partial<{ + [languageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: { + [key in MessageKey_themeDefined]: string | Record; + }; + }>; +}): ReturnTypeOfCreateGetI18n { + const { extraLanguageTranslations, messagesByLanguageTag_themeDefined } = params; + + type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet; + + type I18n = GenericI18n_noJsx; type Result = { i18n: I18n; prI18n_currentLanguage: Promise | undefined }; @@ -104,7 +133,7 @@ export function createGetI18n(me } const partialI18n: Pick = { - currentLanguageTag: kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG, + currentLanguageTag: kcContext.locale?.currentLanguageTag ?? (FALLBACK_LANGUAGE_TAG as any), getChangeLocaleUrl: newLanguageTag => { const { locale } = kcContext; @@ -120,15 +149,16 @@ export function createGetI18n(me }; const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory({ + themeName: kcContext.themeName, messages_themeDefined: messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ?? - messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG] ?? + messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG as LanguageTag] ?? (() => { const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0]; if (firstLanguageTag === undefined) { return undefined; } - return messagesByLanguageTag_themeDefined[firstLanguageTag]; + return messagesByLanguageTag_themeDefined[firstLanguageTag as LanguageTag]; })(), messages_fromKcServer: kcContext["x-keycloakify"].messages }); @@ -146,7 +176,29 @@ export function createGetI18n(me prI18n_currentLanguage: isCurrentLanguageFallbackLanguage ? undefined : (async () => { - const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag); + const messages_defaultSet_currentLanguage = await (async () => { + const currentLanguageTag = partialI18n.currentLanguageTag; + + const fromDefaultSet = await fetchMessages_defaultSet(currentLanguageTag); + + const isEmpty = (() => { + for (let _key in fromDefaultSet) { + return false; + } + + return true; + })(); + + if (isEmpty) { + assert(is>(currentLanguageTag)); + + const asyncFunction = extraLanguageTranslations[currentLanguageTag]; + + assert(asyncFunction !== undefined); + + return asyncFunction().then(({ default: messages }) => messages); + } + })(); const i18n_currentLanguage: I18n = { ...partialI18n, @@ -170,18 +222,22 @@ export function createGetI18n(me return result; } - return { getI18n }; + return { + getI18n, + ofTypeI18n: Reflect() + }; } function createI18nTranslationFunctionsFactory(params: { - messages_themeDefined: Record | undefined; + themeName: string; + messages_themeDefined: Record> | undefined; messages_fromKcServer: Record; }) { - const { messages_themeDefined, messages_fromKcServer } = params; + const { themeName, messages_themeDefined, messages_fromKcServer } = params; function createI18nTranslationFunctions(params: { messages_defaultSet_currentLanguage: Partial> | undefined; - }): Pick, "msgStr" | "advancedMsgStr"> { + }): Pick, "msgStr" | "advancedMsgStr"> { const { messages_defaultSet_currentLanguage } = params; function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined { @@ -189,7 +245,23 @@ function createI18nTranslationFunctionsFactory>(messages_fromKcServer)[key] ?? - id | undefined>(messages_themeDefined)?.[key] ?? + (() => { + const messageOrMap = id | undefined> | undefined>(messages_themeDefined)?.[key]; + + if (messageOrMap === undefined) { + return undefined; + } + + if (typeof messageOrMap === "string") { + return messageOrMap; + } + + const message = messageOrMap[themeName]; + + assert(message !== undefined, `No translation for theme variant "${themeName}" for key "${key}"`); + + return message; + })() ?? id | undefined>(messages_defaultSet_currentLanguage)?.[key] ?? id>(messages_defaultSet_fallbackLanguage)[key]; diff --git a/src/login/i18n/index.ts b/src/login/i18n/index.ts index f492a960..1d21e21d 100644 --- a/src/login/i18n/index.ts +++ b/src/login/i18n/index.ts @@ -2,4 +2,4 @@ import type { GenericI18n } from "./GenericI18n"; import type { MessageKey_defaultSet, KcContextLike } from "./i18n"; export type { MessageKey_defaultSet, KcContextLike }; export type I18n = GenericI18n; -export { createUseI18n } from "./useI18n"; +export { createUseI18n, i18nApi } from "./useI18n"; diff --git a/src/login/i18n/pinApi.ts b/src/login/i18n/pinApi.ts new file mode 100644 index 00000000..8db3a2d4 --- /dev/null +++ b/src/login/i18n/pinApi.ts @@ -0,0 +1,130 @@ +import type { LanguageTag as LanguageTag_defaultSet } from "keycloakify/login/i18n/messages_defaultSet/LanguageTag"; +import { + type MessageKey_defaultSet, + type ReturnTypeOfCreateGetI18n, + createGetI18n +} from "./i18n"; + +export type I18nInitializer< + ThemeName extends string = never, + MessageKey_themeDefined extends string = never, + LanguageTag_notInDefaultSet extends string = never, + ExcludedMethod extends + | "withThemeName" + | "withExtraLanguages" + | "withCustomTranslations" = never +> = Omit< + { + withThemeName: () => I18nInitializer< + ThemeName, + MessageKey_themeDefined, + LanguageTag_notInDefaultSet, + ExcludedMethod | "withThemeName" + >; + withExtraLanguages: < + LanguageTag_notInDefaultSet extends string + >(extraLanguageTranslations: { + [LanguageTag in LanguageTag_notInDefaultSet]: () => Promise< + Record + >; + }) => I18nInitializer< + ThemeName, + MessageKey_themeDefined, + LanguageTag_notInDefaultSet, + ExcludedMethod | "withExtraLanguages" + >; + withCustomTranslations: ( + messagesByLanguageTag_themeDefined: Partial<{ + [LanguageTag in + | LanguageTag_defaultSet + | LanguageTag_notInDefaultSet]: Record< + MessageKey_themeDefined, + string | Record + >; + }> + ) => I18nInitializer< + ThemeName, + MessageKey_themeDefined, + LanguageTag_notInDefaultSet, + ExcludedMethod | "withCustomTranslations" + >; + create: () => ReturnTypeOfCreateGetI18n< + MessageKey_themeDefined, + LanguageTag_notInDefaultSet + >; + }, + ExcludedMethod +>; + +function createI18nInitializer< + ThemeName extends string = never, + MessageKey_themeDefined extends string = never, + LanguageTag_notInDefaultSet extends string = never +>(params: { + extraLanguageTranslations: { + [LanguageTag in LanguageTag_notInDefaultSet]: () => Promise< + Record + >; + }; + messagesByLanguageTag_themeDefined: Partial<{ + [LanguageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: Record< + MessageKey_themeDefined, + string | Record + >; + }>; +}): I18nInitializer { + const i18nInitializer: I18nInitializer< + ThemeName, + MessageKey_themeDefined, + LanguageTag_notInDefaultSet + > = { + withThemeName: () => + createI18nInitializer({ + extraLanguageTranslations: params.extraLanguageTranslations, + messagesByLanguageTag_themeDefined: + params.messagesByLanguageTag_themeDefined as any + }), + withExtraLanguages: extraLanguageTranslations => + createI18nInitializer({ + extraLanguageTranslations, + messagesByLanguageTag_themeDefined: + params.messagesByLanguageTag_themeDefined as any + }), + withCustomTranslations: messagesByLanguageTag_themeDefined => + createI18nInitializer({ + extraLanguageTranslations: params.extraLanguageTranslations, + messagesByLanguageTag_themeDefined + }), + create: () => + createGetI18n({ + extraLanguageTranslations: params.extraLanguageTranslations, + messagesByLanguageTag_themeDefined: + params.messagesByLanguageTag_themeDefined + }) + }; + + return i18nInitializer; +} + +export const i18nInitializer = createI18nInitializer({}); + +const i18n = i18nInitializer + .withThemeName<"my-theme-1" | "my-theme-2">() + .withExtraLanguages({ + xx: async () => ({}) as any + }) + .withCustomTranslations({ + en: { + myCustomKey: { + "my-theme-1": "my-theme-1-en", + "my-theme-2": "my-theme-2-en" + } + }, + xx: { + myCustomKey: { + "my-theme-1": "my-theme-1-xx", + "my-theme-2": "my-theme-2-xx" + } + } + }) + .create(); diff --git a/src/login/i18n/useI18n.tsx b/src/login/i18n/useI18n.tsx index fb19f1c4..6f73d259 100644 --- a/src/login/i18n/useI18n.tsx +++ b/src/login/i18n/useI18n.tsx @@ -2,16 +2,33 @@ import { useEffect, useState } from "react"; import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n"; import { GenericI18n } from "./GenericI18n"; import { Reflect } from "tsafe/Reflect"; +import type { LanguageTag as LanguageTag_defaultSet } from "keycloakify/login/i18n/messages_defaultSet/LanguageTag"; -export function createUseI18n(messagesByLanguageTag: { - [languageTag: string]: { [key in MessageKey_themeDefined]: string }; +export const i18nApi = { + withThemeName: () => ({ + withTranslations: (messagesByLanguageTag: { + [languageTag: string]: { [key in MessageKey_themeDefined]: string | Record }; + }) => ({ + create: () => createUseI18n(messagesByLanguageTag) + }) + }) +}; + +export function createUseI18n< + MessageKey_themeDefined extends string = never, + ThemeName extends string = string, + LanguageTag extends string = LanguageTag_defaultSet +>(params: { + messagesByLanguageTag: { + [languageTag: string]: { [key in MessageKey_themeDefined]: string | Record }; + }; }) { type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined; - type I18n = GenericI18n; + type I18n = GenericI18n; const { withJsx } = (() => { - const cache = new WeakMap, GenericI18n>(); + const cache = new WeakMap, GenericI18n>(); function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element { const { htmlString, msgKey } = params; @@ -25,7 +42,7 @@ export function createUseI18n(me ); } - function withJsx(i18n_noJsx: GenericI18n_noJsx): I18n { + function withJsx(i18n_noJsx: GenericI18n_noJsx): I18n { use_cache: { const i18n = cache.get(i18n_noJsx); @@ -63,7 +80,7 @@ export function createUseI18n(me (styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement); } - const { getI18n } = createGetI18n(messagesByLanguageTag); + const { getI18n } = createGetI18n({ messagesByLanguageTag, extraLanguageTranslations }); function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } { const { kcContext } = params; diff --git a/src/login/index.ts b/src/login/index.ts index 0ccca8b9..a6718f84 100644 --- a/src/login/index.ts +++ b/src/login/index.ts @@ -1,3 +1,3 @@ export type { ExtendKcContext, Attribute } from "keycloakify/login/KcContext"; export type { ClassKey } from "keycloakify/login/TemplateProps"; -export { createUseI18n } from "keycloakify/login/i18n"; +export { createUseI18n, i18nApi } from "keycloakify/login/i18n"; diff --git a/stories/login/i18n.ts b/stories/login/i18n.ts index 27a865ad..39eced84 100644 --- a/stories/login/i18n.ts +++ b/stories/login/i18n.ts @@ -1,5 +1,9 @@ -import { createUseI18n } from "../../dist/login"; +import { i18nApi } from "../../dist/login"; +import type { ThemeName } from "../kc.gen"; -export const { useI18n, ofTypeI18n } = createUseI18n({}); +export const { useI18n, ofTypeI18n } = i18nApi + .withThemeName() + .withTranslations({}) + .create(); export type I18n = typeof ofTypeI18n;