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";
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;
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 { 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<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"
*
* 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<MessageKey extends string> = {
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
export function createGetI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag_themeDefined: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>;
export type ReturnTypeOfCreateGetI18n<MessageKey_themeDefined extends string, LanguageTag_notInDefaultSet extends string> = {
getI18n: (params: { kcContext: KcContextLike }) => {
i18n: GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
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 };
@ -104,7 +133,7 @@ export function createGetI18n<MessageKey_themeDefined extends string = never>(me
}
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 => {
const { locale } = kcContext;
@ -120,15 +149,16 @@ export function createGetI18n<MessageKey_themeDefined extends string = never>(me
};
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
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<MessageKey_themeDefined extends string = never>(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<Exclude<LanguageTag, LanguageTag_defaultSet>>(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<MessageKey_themeDefined extends string = never>(me
return result;
}
return { getI18n };
return {
getI18n,
ofTypeI18n: Reflect<I18n>()
};
}
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>;
}) {
const { messages_themeDefined, messages_fromKcServer } = params;
const { themeName, messages_themeDefined, messages_fromKcServer } = params;
function createI18nTranslationFunctions(params: {
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;
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
@ -189,7 +245,23 @@ function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends s
const message =
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>>(messages_defaultSet_fallbackLanguage)[key];

View File

@ -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<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 { GenericI18n } from "./GenericI18n";
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: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
export const i18nApi = {
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 I18n = GenericI18n<MessageKey>;
type I18n = GenericI18n<MessageKey, LanguageTag>;
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 {
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: {
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);
}
const { getI18n } = createGetI18n(messagesByLanguageTag);
const { getI18n } = createGetI18n({ messagesByLanguageTag, extraLanguageTranslations });
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params;

View File

@ -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";

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;