Complete runtime API implementation

This commit is contained in:
Joseph Garrone 2024-09-21 17:59:16 +02:00
parent 40ebbdebeb
commit 969744f4cb
13 changed files with 289 additions and 126 deletions

View File

@ -140,13 +140,15 @@ async function generateI18nMessages() {
);
fs.writeFileSync(
pathJoin(messagesDirPath, "LanguageTag.ts"),
pathJoin(messagesDirPath, "types.ts"),
Buffer.from(
[
``,
`export const languageTags = ${JSON.stringify(languages, null, 2)} as const;`,
``,
`export type LanguageTag = typeof languageTags[number];`,
``,
`export type MessageKey = keyof typeof import("./en")["default"];`,
``
].join("\n"),
"utf8"

View File

@ -29,9 +29,7 @@ export function generateMessageProperties(params: {
Object.fromEntries(
fs
.readdirSync(baseMessagesDirPath)
.filter(
basename => basename !== "index.ts" && basename !== "LanguageTag.ts"
)
.filter(basename => basename !== "index.ts" && basename !== "types.ts")
.map(basename => ({
languageTag: basename.replace(/\.ts$/, ""),
filePath: pathJoin(baseMessagesDirPath, basename)

View File

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

View File

@ -0,0 +1,60 @@
export type GenericI18n_noJsx<MessageKey extends string, LanguageTag extends string> = {
/**
* e.g: "en", "fr", "zh-CN"
*
* The current language
*/
currentLanguageTag: LanguageTag;
/**
* Redirect to this url to change the language.
* After reload currentLanguageTag === newLanguageTag
*/
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, string>;
/**
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
* msgStr("${bar}", "<strong>c</strong>") === "Bar &lt;strong&gt;XXX&lt;/strong&gt;"
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
*/
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
/**
* This is meant to be used when the key argument is variable, something that might have been configured by the user
* in the Keycloak admin for example.
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* }
* }
*
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
};

View File

@ -1,13 +1,14 @@
import "keycloakify/tools/Object.fromEntries";
import { assert } from "tsafe/assert";
import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en";
import { fetchMessages_defaultSet } from "./messages_defaultSet";
import type { KcContext } from "../KcContext";
import messages_defaultSet_fallbackLanguage from "../messages_defaultSet/en";
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";
import type { LanguageTag as LanguageTag_defaultSet, MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";
import type { GenericI18n_noJsx } from "./GenericI18n_noJsx";
export type KcContextLike = {
themeName: string;
@ -22,69 +23,6 @@ export type KcContextLike = {
assert<KcContext extends KcContextLike ? true : false>();
export type GenericI18n_noJsx<MessageKey extends string, LanguageTag extends string> = {
/**
* e.g: "en", "fr", "zh-CN"
*
* The current language
*/
currentLanguageTag: LanguageTag;
/**
* Redirect to this url to change the language.
* After reload currentLanguageTag === newLanguageTag
*/
getChangeLocaleUrl: (newLanguageTag: 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, string>;
/**
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
* msgStr("${bar}", "<strong>c</strong>") === "Bar &lt;strong&gt;XXX&lt;/strong&gt;"
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
*/
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
/**
* This is meant to be used when the key argument is variable, something that might have been configured by the user
* in the Keycloak admin for example.
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* }
* }
*
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
};
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
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>;
@ -113,7 +51,9 @@ export function createGetI18n<
type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet;
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag>;
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
type I18n = GenericI18n_noJsx<MessageKey, LanguageTag>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };

View File

@ -1,9 +1,8 @@
import type { LanguageTag as LanguageTag_defaultSet } from "keycloakify/login/i18n/messages_defaultSet/LanguageTag";
import {
type MessageKey_defaultSet,
type ReturnTypeOfCreateGetI18n,
createGetI18n
} from "./i18n";
import type {
LanguageTag as LanguageTag_defaultSet,
MessageKey as MessageKey_defaultSet
} from "keycloakify/login/i18n/messages_defaultSet/types";
import { type ReturnTypeOfCreateGetI18n, createGetI18n } from "./getI18n";
export type I18nInitializer<
ThemeName extends string = never,
@ -110,26 +109,3 @@ export const i18nInitializer = createI18nInitializer({
extraLanguageTranslations: {},
messagesByLanguageTag_themeDefined: {}
});
/*
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

@ -0,0 +1,2 @@
export { i18nInitializer } from "./i18nInitializer";
export type { MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";

View File

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

View File

@ -0,0 +1,111 @@
import type {
LanguageTag as LanguageTag_defaultSet,
MessageKey as MessageKey_defaultSet
} from "../messages_defaultSet/types";
import { type ReturnTypeOfCreateUseI18n, createUseI18n } from "../withJsx/useI18n";
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<{
default: 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: () => ReturnTypeOfCreateUseI18n<
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<{
default: 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: () =>
createUseI18n({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined
})
};
return i18nInitializer;
}
export const i18nInitializer = createI18nInitializer({
extraLanguageTranslations: {},
messagesByLanguageTag_themeDefined: {}
});

View File

@ -0,0 +1,2 @@
export { i18nInitializer } from "./i18nInitializer";
export type { MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";

View File

@ -1,32 +1,41 @@
import { useEffect, useState } from "react";
import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n";
import { GenericI18n } from "./GenericI18n";
import { createGetI18n, type KcContextLike } from "../noJsx/getI18n";
import type { GenericI18n_noJsx } from "../noJsx/GenericI18n_noJsx";
import { Reflect } from "tsafe/Reflect";
import type { LanguageTag as LanguageTag_defaultSet } from "keycloakify/login/i18n/messages_defaultSet/LanguageTag";
import type { GenericI18n } from "./GenericI18n";
import type { LanguageTag as LanguageTag_defaultSet, MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";
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 type ReturnTypeOfCreateUseI18n<MessageKey_themeDefined extends string, LanguageTag_notInDefaultSet extends string> = {
useI18n: (params: { kcContext: KcContextLike }) => {
i18n: GenericI18n<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
};
ofTypeI18n: GenericI18n<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
};
export function createUseI18n<
MessageKey_themeDefined extends string = never,
ThemeName extends string = string,
LanguageTag extends string = LanguageTag_defaultSet
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never
>(params: {
messagesByLanguageTag: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string | Record<ThemeName, string> };
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>;
};
}>;
}): ReturnTypeOfCreateUseI18n<MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
const { extraLanguageTranslations, messagesByLanguageTag_themeDefined } = params;
type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet;
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
type I18n = GenericI18n<MessageKey, LanguageTag>;
type Result = { i18n: I18n };
const { withJsx } = (() => {
const cache = new WeakMap<GenericI18n_noJsx<MessageKey, LanguageTag>, GenericI18n<MessageKey, LanguageTag>>();
@ -80,9 +89,9 @@ export function createUseI18n<
(styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement);
}
const { getI18n } = createGetI18n({ messagesByLanguageTag, extraLanguageTranslations });
const { getI18n } = createGetI18n({ extraLanguageTranslations, messagesByLanguageTag_themeDefined });
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
function useI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });

7
test/login/he.ts Normal file
View File

@ -0,0 +1,7 @@
import type { MessageKey_defaultSet } from "keycloakify/login/i18n";
/* spell-checker: disable */
const messages: Record<MessageKey_defaultSet, string> = null as any;
/* spell-checker: enable */
export default messages;

View File

@ -0,0 +1,54 @@
import { i18nInitializer } from "keycloakify/login/i18n";
import { assert, type Equals } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
const { useI18n, ofTypeI18n } = i18nInitializer
.withThemeName<"my-theme-1" | "my-theme-2">()
.withExtraLanguages({
he: () => import("./he")
})
.withCustomTranslations({
en: {
myCustomKey1: "my-custom-key-1-en",
myCustomKey2: {
"my-theme-1": "my-theme-1-en",
"my-theme-2": "my-theme-2-en"
}
},
he: {
myCustomKey1: "my-custom-key-1-he",
myCustomKey2: {
"my-theme-1": "my-theme-1-xx",
"my-theme-2": "my-theme-2-xx"
}
}
})
.create();
type I18n = typeof ofTypeI18n;
{
const { i18n } = useI18n({ kcContext: Reflect<any>() });
assert<Equals<typeof i18n, I18n>>;
}
{
const i18n = Reflect<I18n>();
const got = i18n.currentLanguageTag;
type Expected =
| import("keycloakify/login/i18n/messages_defaultSet/types").LanguageTag
| "he";
assert<Equals<typeof got, Expected>>;
}
{
const i18n = Reflect<I18n>();
const node = i18n.msg("myCustomKey2");
assert<Equals<typeof node, JSX.Element>>;
}