Start implementing per theme variant translations and ability to add extra languages

This commit is contained in:
Joseph Garrone 2024-09-15 16:55:18 +02:00
parent e15f13646c
commit aad89a2001
7 changed files with 250 additions and 27 deletions

View File

@ -1,6 +1,6 @@
import type { GenericI18n_noJsx } from "./i18n"; import type { GenericI18n_noJsx } from "./i18n";
export type GenericI18n<MessageKey extends string> = GenericI18n_noJsx<MessageKey> & { export type GenericI18n<MessageKey extends string, LanguageTag extends string> = GenericI18n_noJsx<MessageKey, LanguageTag> & {
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element; msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element; advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
}; };

View File

@ -5,8 +5,12 @@ import { fetchMessages_defaultSet } from "./messages_defaultSet";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants"; import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id"; 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 = { export type KcContextLike = {
themeName: string;
locale?: { locale?: {
currentLanguageTag: string; currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[]; supported: { languageTag: string; url: string; label: string }[];
@ -18,18 +22,18 @@ export type KcContextLike = {
assert<KcContext extends KcContextLike ? true : false>(); assert<KcContext extends KcContextLike ? true : false>();
export type GenericI18n_noJsx<MessageKey extends string> = { export type GenericI18n_noJsx<MessageKey extends string, LanguageTag extends string> = {
/** /**
* e.g: "en", "fr", "zh-CN" * e.g: "en", "fr", "zh-CN"
* *
* The current language * The current language
*/ */
currentLanguageTag: string; currentLanguageTag: LanguageTag;
/** /**
* Redirect to this url to change the language. * Redirect to this url to change the language.
* After reload currentLanguageTag === newLanguageTag * After reload currentLanguageTag === newLanguageTag
*/ */
getChangeLocaleUrl: (newLanguageTag: string) => string; getChangeLocaleUrl: (newLanguageTag: LanguageTag) => string;
/** /**
* e.g. "en" => "English", "fr" => "Français", ... * e.g. "en" => "English", "fr" => "Français", ...
* *
@ -81,10 +85,35 @@ export type GenericI18n_noJsx<MessageKey extends string> = {
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage; export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
export function createGetI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag_themeDefined: { export type ReturnTypeOfCreateGetI18n<MessageKey_themeDefined extends string, LanguageTag_notInDefaultSet extends string> = {
[languageTag: string]: { [key in MessageKey_themeDefined]: string }; getI18n: (params: { kcContext: KcContextLike }) => {
}) { i18n: GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>; prI18n_currentLanguage:
| Promise<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>>
| undefined;
};
ofTypeI18n: GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
};
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<MessageKey_defaultSet, string> }>;
};
messagesByLanguageTag_themeDefined: Partial<{
[languageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: {
[key in MessageKey_themeDefined]: string | Record<ThemeName, string>;
};
}>;
}): ReturnTypeOfCreateGetI18n<MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
const { extraLanguageTranslations, messagesByLanguageTag_themeDefined } = params;
type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet;
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined }; type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
@ -104,7 +133,7 @@ export function createGetI18n<MessageKey_themeDefined extends string = never>(me
} }
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocaleUrl" | "labelBySupportedLanguageTag"> = { const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocaleUrl" | "labelBySupportedLanguageTag"> = {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG, currentLanguageTag: kcContext.locale?.currentLanguageTag ?? (FALLBACK_LANGUAGE_TAG as any),
getChangeLocaleUrl: newLanguageTag => { getChangeLocaleUrl: newLanguageTag => {
const { locale } = kcContext; const { locale } = kcContext;
@ -120,15 +149,16 @@ export function createGetI18n<MessageKey_themeDefined extends string = never>(me
}; };
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({ const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
themeName: kcContext.themeName,
messages_themeDefined: messages_themeDefined:
messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ?? messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ??
messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG] ?? messagesByLanguageTag_themeDefined[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) {
return undefined; return undefined;
} }
return messagesByLanguageTag_themeDefined[firstLanguageTag]; return messagesByLanguageTag_themeDefined[firstLanguageTag as LanguageTag];
})(), })(),
messages_fromKcServer: kcContext["x-keycloakify"].messages messages_fromKcServer: kcContext["x-keycloakify"].messages
}); });
@ -146,7 +176,29 @@ export function createGetI18n<MessageKey_themeDefined extends string = never>(me
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined ? undefined
: (async () => { : (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<Exclude<LanguageTag, LanguageTag_defaultSet>>(currentLanguageTag));
const asyncFunction = extraLanguageTranslations[currentLanguageTag];
assert(asyncFunction !== undefined);
return asyncFunction().then(({ default: messages }) => messages);
}
})();
const i18n_currentLanguage: I18n = { const i18n_currentLanguage: I18n = {
...partialI18n, ...partialI18n,
@ -170,18 +222,22 @@ export function createGetI18n<MessageKey_themeDefined extends string = never>(me
return result; return result;
} }
return { getI18n }; return {
getI18n,
ofTypeI18n: Reflect<I18n>()
};
} }
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: { function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
messages_themeDefined: Record<MessageKey_themeDefined, string> | undefined; themeName: string;
messages_themeDefined: Record<MessageKey_themeDefined, string | Record<string, string>> | undefined;
messages_fromKcServer: Record<string, string>; messages_fromKcServer: Record<string, string>;
}) { }) {
const { messages_themeDefined, messages_fromKcServer } = params; const { themeName, messages_themeDefined, messages_fromKcServer } = params;
function createI18nTranslationFunctions(params: { function createI18nTranslationFunctions(params: {
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined; messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>, "msgStr" | "advancedMsgStr"> { }): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, string>, "msgStr" | "advancedMsgStr"> {
const { messages_defaultSet_currentLanguage } = params; const { messages_defaultSet_currentLanguage } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined { function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
@ -189,7 +245,23 @@ function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends s
const message = const message =
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ?? id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
id<Record<string, string | undefined> | undefined>(messages_themeDefined)?.[key] ?? (() => {
const messageOrMap = id<Record<string, string | Record<string, string> | 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<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ?? id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key]; id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];

View File

@ -2,4 +2,4 @@ import type { GenericI18n } from "./GenericI18n";
import type { MessageKey_defaultSet, KcContextLike } from "./i18n"; import type { MessageKey_defaultSet, KcContextLike } from "./i18n";
export type { MessageKey_defaultSet, KcContextLike }; export type { MessageKey_defaultSet, KcContextLike };
export type I18n = GenericI18n<MessageKey_defaultSet>; export type I18n = GenericI18n<MessageKey_defaultSet>;
export { createUseI18n } from "./useI18n"; export { createUseI18n, i18nApi } from "./useI18n";

130
src/login/i18n/pinApi.ts Normal file
View File

@ -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: <ThemeName extends string>() => I18nInitializer<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withThemeName"
>;
withExtraLanguages: <
LanguageTag_notInDefaultSet extends string
>(extraLanguageTranslations: {
[LanguageTag in LanguageTag_notInDefaultSet]: () => Promise<
Record<MessageKey_defaultSet, string>
>;
}) => I18nInitializer<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withExtraLanguages"
>;
withCustomTranslations: <MessageKey_themeDefined extends string>(
messagesByLanguageTag_themeDefined: Partial<{
[LanguageTag in
| LanguageTag_defaultSet
| LanguageTag_notInDefaultSet]: Record<
MessageKey_themeDefined,
string | Record<ThemeName, string>
>;
}>
) => 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<MessageKey_defaultSet, string>
>;
};
messagesByLanguageTag_themeDefined: Partial<{
[LanguageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: Record<
MessageKey_themeDefined,
string | Record<ThemeName, string>
>;
}>;
}): I18nInitializer<ThemeName, MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
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();

View File

@ -2,16 +2,33 @@ import { useEffect, useState } from "react";
import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n"; import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n";
import { GenericI18n } from "./GenericI18n"; import { GenericI18n } from "./GenericI18n";
import { Reflect } from "tsafe/Reflect"; import { Reflect } from "tsafe/Reflect";
import type { LanguageTag as LanguageTag_defaultSet } from "keycloakify/login/i18n/messages_defaultSet/LanguageTag";
export function createUseI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag: { export const i18nApi = {
[languageTag: string]: { [key in MessageKey_themeDefined]: string }; withThemeName: <ThemeName extends string>() => ({
withTranslations: <MessageKey_themeDefined extends string = never>(messagesByLanguageTag: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string | Record<ThemeName, string> };
}) => ({
create: () => createUseI18n<MessageKey_themeDefined, ThemeName>(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<ThemeName, string> };
};
}) { }) {
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined; type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
type I18n = GenericI18n<MessageKey>; type I18n = GenericI18n<MessageKey, LanguageTag>;
const { withJsx } = (() => { const { withJsx } = (() => {
const cache = new WeakMap<GenericI18n_noJsx<MessageKey>, GenericI18n<MessageKey>>(); const cache = new WeakMap<GenericI18n_noJsx<MessageKey, LanguageTag>, GenericI18n<MessageKey, LanguageTag>>();
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element { function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
const { htmlString, msgKey } = params; const { htmlString, msgKey } = params;
@ -25,7 +42,7 @@ export function createUseI18n<MessageKey_themeDefined extends string = never>(me
); );
} }
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey>): I18n { function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey, LanguageTag>): I18n {
use_cache: { use_cache: {
const i18n = cache.get(i18n_noJsx); const i18n = cache.get(i18n_noJsx);
@ -63,7 +80,7 @@ export function createUseI18n<MessageKey_themeDefined extends string = never>(me
(styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement); (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 } { function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params; const { kcContext } = params;

View File

@ -1,3 +1,3 @@
export type { ExtendKcContext, Attribute } from "keycloakify/login/KcContext"; export type { ExtendKcContext, Attribute } from "keycloakify/login/KcContext";
export type { ClassKey } from "keycloakify/login/TemplateProps"; export type { ClassKey } from "keycloakify/login/TemplateProps";
export { createUseI18n } from "keycloakify/login/i18n"; export { createUseI18n, i18nApi } from "keycloakify/login/i18n";

View File

@ -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<ThemeName>()
.withTranslations({})
.create();
export type I18n = typeof ofTypeI18n; export type I18n = typeof ofTypeI18n;