diff --git a/scripts/generate-i18n-messages.ts b/scripts/generate-i18n-messages.ts index 312a3e5a..20956464 100644 --- a/scripts/generate-i18n-messages.ts +++ b/scripts/generate-i18n-messages.ts @@ -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" diff --git a/src/bin/keycloakify/generateResources/generateMessageProperties.ts b/src/bin/keycloakify/generateResources/generateMessageProperties.ts index 48c54a3e..512bbaad 100644 --- a/src/bin/keycloakify/generateResources/generateMessageProperties.ts +++ b/src/bin/keycloakify/generateResources/generateMessageProperties.ts @@ -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) diff --git a/src/login/i18n/index.ts b/src/login/i18n/index.ts index 2431dc91..e37f77d5 100644 --- a/src/login/i18n/index.ts +++ b/src/login/i18n/index.ts @@ -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; -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; diff --git a/src/login/i18n/noJsx/GenericI18n_noJsx.ts b/src/login/i18n/noJsx/GenericI18n_noJsx.ts new file mode 100644 index 00000000..a5abce2e --- /dev/null +++ b/src/login/i18n/noJsx/GenericI18n_noJsx.ts @@ -0,0 +1,60 @@ +export type GenericI18n_noJsx = { + /** + * 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; + /** + * + * Examples assuming currentLanguageTag === "en" + * { + * en: { + * "access-denied": "Access denied", + * "impersonateTitleHtml": "{0} Impersonate User", + * "bar": "Bar {0}" + * } + * } + * + * msgStr("access-denied") === "Access denied" + * msgStr("not-a-message-key") Throws an error + * msgStr("impersonateTitleHtml", "Foo") === "Foo Impersonate User" + * msgStr("${bar}", "c") === "Bar <strong>XXX</strong>" + * 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; +}; diff --git a/src/login/i18n/i18n.tsx b/src/login/i18n/noJsx/getI18n.tsx similarity index 78% rename from src/login/i18n/i18n.tsx rename to src/login/i18n/noJsx/getI18n.tsx index fec62c33..3ce51695 100644 --- a/src/login/i18n/i18n.tsx +++ b/src/login/i18n/noJsx/getI18n.tsx @@ -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(); -export type GenericI18n_noJsx = { - /** - * 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; - /** - * - * Examples assuming currentLanguageTag === "en" - * { - * en: { - * "access-denied": "Access denied", - * "impersonateTitleHtml": "{0} Impersonate User", - * "bar": "Bar {0}" - * } - * } - * - * msgStr("access-denied") === "Access denied" - * msgStr("not-a-message-key") Throws an error - * msgStr("impersonateTitleHtml", "Foo") === "Foo Impersonate User" - * msgStr("${bar}", "c") === "Bar <strong>XXX</strong>" - * 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 = { getI18n: (params: { kcContext: KcContextLike }) => { i18n: GenericI18n_noJsx; @@ -113,7 +51,9 @@ export function createGetI18n< type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet; - type I18n = GenericI18n_noJsx; + type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined; + + type I18n = GenericI18n_noJsx; type Result = { i18n: I18n; prI18n_currentLanguage: Promise | undefined }; diff --git a/src/login/i18n/pinApi.ts b/src/login/i18n/noJsx/i18nInitializer.ts similarity index 83% rename from src/login/i18n/pinApi.ts rename to src/login/i18n/noJsx/i18nInitializer.ts index 2ba8aaba..28a4378c 100644 --- a/src/login/i18n/pinApi.ts +++ b/src/login/i18n/noJsx/i18nInitializer.ts @@ -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(); -*/ diff --git a/src/login/i18n/noJsx/index.ts b/src/login/i18n/noJsx/index.ts new file mode 100644 index 00000000..61f02521 --- /dev/null +++ b/src/login/i18n/noJsx/index.ts @@ -0,0 +1,2 @@ +export { i18nInitializer } from "./i18nInitializer"; +export type { MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types"; diff --git a/src/login/i18n/GenericI18n.tsx b/src/login/i18n/withJsx/GenericI18n.tsx similarity index 80% rename from src/login/i18n/GenericI18n.tsx rename to src/login/i18n/withJsx/GenericI18n.tsx index 7badb61e..1b37579e 100644 --- a/src/login/i18n/GenericI18n.tsx +++ b/src/login/i18n/withJsx/GenericI18n.tsx @@ -1,4 +1,4 @@ -import type { GenericI18n_noJsx } from "./i18n"; +import type { GenericI18n_noJsx } from "../noJsx/GenericI18n_noJsx"; export type GenericI18n = GenericI18n_noJsx & { msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element; diff --git a/src/login/i18n/withJsx/i18nInitializer.ts b/src/login/i18n/withJsx/i18nInitializer.ts new file mode 100644 index 00000000..e7379e8e --- /dev/null +++ b/src/login/i18n/withJsx/i18nInitializer.ts @@ -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: () => I18nInitializer< + ThemeName, + MessageKey_themeDefined, + LanguageTag_notInDefaultSet, + ExcludedMethod | "withThemeName" + >; + withExtraLanguages: < + LanguageTag_notInDefaultSet extends string + >(extraLanguageTranslations: { + [LanguageTag in LanguageTag_notInDefaultSet]: () => Promise<{ + default: 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: () => 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; + }>; + }; + 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: () => + createUseI18n({ + extraLanguageTranslations: params.extraLanguageTranslations, + messagesByLanguageTag_themeDefined: + params.messagesByLanguageTag_themeDefined + }) + }; + + return i18nInitializer; +} + +export const i18nInitializer = createI18nInitializer({ + extraLanguageTranslations: {}, + messagesByLanguageTag_themeDefined: {} +}); diff --git a/src/login/i18n/withJsx/index.ts b/src/login/i18n/withJsx/index.ts new file mode 100644 index 00000000..61f02521 --- /dev/null +++ b/src/login/i18n/withJsx/index.ts @@ -0,0 +1,2 @@ +export { i18nInitializer } from "./i18nInitializer"; +export type { MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types"; diff --git a/src/login/i18n/useI18n.tsx b/src/login/i18n/withJsx/useI18n.tsx similarity index 62% rename from src/login/i18n/useI18n.tsx rename to src/login/i18n/withJsx/useI18n.tsx index 6f73d259..37f7f55a 100644 --- a/src/login/i18n/useI18n.tsx +++ b/src/login/i18n/withJsx/useI18n.tsx @@ -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: () => ({ - withTranslations: (messagesByLanguageTag: { - [languageTag: string]: { [key in MessageKey_themeDefined]: string | Record }; - }) => ({ - create: () => createUseI18n(messagesByLanguageTag) - }) - }) +export type ReturnTypeOfCreateUseI18n = { + useI18n: (params: { kcContext: KcContextLike }) => { + i18n: GenericI18n; + }; + ofTypeI18n: GenericI18n; }; 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 }; + extraLanguageTranslations: { + [languageTag in LanguageTag_notInDefaultSet]: () => Promise<{ default: Record }>; }; -}) { + messagesByLanguageTag_themeDefined: Partial<{ + [languageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: { + [key in MessageKey_themeDefined]: string | Record; + }; + }>; +}): ReturnTypeOfCreateUseI18n { + const { extraLanguageTranslations, messagesByLanguageTag_themeDefined } = params; + + type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet; + type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined; type I18n = GenericI18n; + type Result = { i18n: I18n }; + const { withJsx } = (() => { const cache = new WeakMap, GenericI18n>(); @@ -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 }); diff --git a/test/login/he.ts b/test/login/he.ts new file mode 100644 index 00000000..e9c62f69 --- /dev/null +++ b/test/login/he.ts @@ -0,0 +1,7 @@ +import type { MessageKey_defaultSet } from "keycloakify/login/i18n"; + +/* spell-checker: disable */ +const messages: Record = null as any; +/* spell-checker: enable */ + +export default messages; diff --git a/test/login/i18n.typelevel-spec.ts b/test/login/i18n.typelevel-spec.ts new file mode 100644 index 00000000..af253ccd --- /dev/null +++ b/test/login/i18n.typelevel-spec.ts @@ -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() }); + + assert>; +} + +{ + const i18n = Reflect(); + + const got = i18n.currentLanguageTag; + + type Expected = + | import("keycloakify/login/i18n/messages_defaultSet/types").LanguageTag + | "he"; + + assert>; +} + +{ + const i18n = Reflect(); + + const node = i18n.msg("myCustomKey2"); + + assert>; +}