From 326411ca5db56dad8afbf536e2fae72eb52c4ae1 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 21 Dec 2024 12:08:43 +0100 Subject: [PATCH] Correctly generate i18n messages for admin UI --- .../generateResources/generateResources.ts | 168 +++++++++++++++++- src/bin/main.ts | 7 +- ...etUiModuleFileSourceCodeReadyToBeCopied.ts | 10 +- .../tools/createObjectThatThrowsIfAccessed.ts | 90 ++++++++++ 4 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 src/bin/tools/createObjectThatThrowsIfAccessed.ts diff --git a/src/bin/keycloakify/generateResources/generateResources.ts b/src/bin/keycloakify/generateResources/generateResources.ts index 1323e9fe..4b3881a0 100644 --- a/src/bin/keycloakify/generateResources/generateResources.ts +++ b/src/bin/keycloakify/generateResources/generateResources.ts @@ -40,6 +40,7 @@ import { escapeStringForPropertiesFile } from "../../tools/escapeStringForProper import * as child_process from "child_process"; import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath"; import propertiesParser from "properties-parser"; +import { createObjectThatThrowsIfAccessed } from "../../tools/createObjectThatThrowsIfAccessed"; export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode & BuildContextLike_generateMessageProperties & { @@ -256,15 +257,17 @@ export async function generateResources(params: { writeMessagePropertiesFiles; } - bring_in_spas_messages: { + bring_in_account_spa_messages: { if (!isSpa) { - break bring_in_spas_messages; + break bring_in_account_spa_messages; } - assert(themeType !== "login"); + if (themeType !== "account") { + break bring_in_account_spa_messages; + } const accountUiDirPath = child_process - .execSync(`npm list @keycloakify/keycloak-${themeType}-ui --parseable`, { + .execSync(`npm list @keycloakify/keycloak-account-ui --parseable`, { cwd: pathDirname(buildContext.packageJsonFilePath) }) .toString("utf8") @@ -279,7 +282,7 @@ export async function generateResources(params: { } const messagesDirPath_dest = pathJoin( - getThemeTypeDirPath({ themeName, themeType }), + getThemeTypeDirPath({ themeName, themeType: "account" }), "messages" ); @@ -291,7 +294,7 @@ export async function generateResources(params: { apply_theme_changes: { const messagesDirPath_theme = pathJoin( buildContext.themeSrcDirPath, - themeType, + "account", "messages" ); @@ -339,6 +342,159 @@ export async function generateResources(params: { ); } + bring_in_admin_messages: { + if (themeType !== "admin") { + break bring_in_admin_messages; + } + + const messagesDirPath_theme = pathJoin( + buildContext.themeSrcDirPath, + "admin", + "i18n" + ); + + assert( + fs.existsSync(messagesDirPath_theme), + `${messagesDirPath_theme} is supposed to exist` + ); + + const propertiesByLang: Record< + string, + { + base: Buffer; + override: Buffer | undefined; + overrideByThemeName: Record; + } + > = {}; + + fs.readdirSync(messagesDirPath_theme).forEach(basename => { + type ParsedBasename = { lang: string } & ( + | { + isOverride: false; + } + | { + isOverride: true; + themeName: string | undefined; + } + ); + + const parsedBasename = ((): ParsedBasename | undefined => { + const match = basename.match(/^messages_([^.]+)\.properties$/); + + if (match === null) { + return undefined; + } + + const discriminator = match[1]; + + const split = discriminator.split("_override"); + + if (split.length === 1) { + return { + lang: discriminator, + isOverride: false + }; + } + + assert(split.length === 2); + + if (split[1] === "") { + return { + lang: split[0], + isOverride: true, + themeName: undefined + }; + } + + const match2 = split[1].match(/^_(.+)$/); + + assert(match2 !== null); + + return { + lang: split[0], + isOverride: true, + themeName: match2[1] + }; + })(); + + if (parsedBasename === undefined) { + return; + } + + propertiesByLang[parsedBasename.lang] ??= { + base: createObjectThatThrowsIfAccessed({ + debugMessage: `No base ${parsedBasename.lang} translation for admin theme` + }), + override: undefined, + overrideByThemeName: {} + }; + + const buffer = fs.readFileSync(pathJoin(messagesDirPath_theme, basename)); + + if (parsedBasename.isOverride === false) { + propertiesByLang[parsedBasename.lang].base = buffer; + return; + } + + if (parsedBasename.themeName === undefined) { + propertiesByLang[parsedBasename.lang].override = buffer; + return; + } + + propertiesByLang[parsedBasename.lang].overrideByThemeName[ + parsedBasename.themeName + ] = buffer; + }); + + writeMessagePropertiesFilesByThemeType.admin = ({ + messageDirPath, + themeName + }) => { + if (!fs.existsSync(messageDirPath)) { + fs.mkdirSync(messageDirPath, { recursive: true }); + } + + Object.entries(propertiesByLang).forEach( + ([lang, { base, override, overrideByThemeName }]) => { + (languageTags ??= []).push(lang); + + const messages = propertiesParser.parse(base.toString("utf8")); + + if (override !== undefined) { + const overrideMessages = propertiesParser.parse( + override.toString("utf8") + ); + + Object.entries(overrideMessages).forEach( + ([key, value]) => (messages[key] = value) + ); + } + + if (themeName in overrideByThemeName) { + const overrideMessages = propertiesParser.parse( + overrideByThemeName[themeName].toString("utf8") + ); + + Object.entries(overrideMessages).forEach( + ([key, value]) => (messages[key] = value) + ); + } + + const editor = propertiesParser.createEditor(); + + Object.entries(messages).forEach(([key, value]) => { + editor.set(key, value); + }); + + fs.writeFileSync( + pathJoin(messageDirPath, `messages_${lang}.properties`), + Buffer.from(editor.toString(), "utf8") + ); + } + ); + }; + } + keycloak_static_resources: { if (isSpa) { break keycloak_static_resources; diff --git a/src/bin/main.ts b/src/bin/main.ts index eac850d2..14deed2c 100644 --- a/src/bin/main.ts +++ b/src/bin/main.ts @@ -259,11 +259,12 @@ program .option({ key: "file", name: (() => { - const name = "file"; + const long = "file"; + const short = "f"; - optionsKeys.push(name); + optionsKeys.push(long, short); - return name; + return { long, short }; })(), description: [ "Relative path of the file relative to the directory of your keycloak theme source", diff --git a/src/bin/postinstall/getUiModuleFileSourceCodeReadyToBeCopied.ts b/src/bin/postinstall/getUiModuleFileSourceCodeReadyToBeCopied.ts index a3903c0c..52284a87 100644 --- a/src/bin/postinstall/getUiModuleFileSourceCodeReadyToBeCopied.ts +++ b/src/bin/postinstall/getUiModuleFileSourceCodeReadyToBeCopied.ts @@ -41,10 +41,18 @@ export async function getUiModuleFileSourceCodeReadyToBeCopied(params: { return [`/**`, ...lines.map(line => ` * ${line}`), ` */`].join("\n"); } - if (fileRelativePath.endsWith(".html") || fileRelativePath.endsWith(".svg")) { + if (fileRelativePath.endsWith(".html")) { return [``].join("\n"); } + if (fileRelativePath.endsWith(".svg")) { + return [ + `` + ].join("\n"); + } + if (fileRelativePath.endsWith(".properties")) { return lines.map(line => `# ${line}`).join("\n"); } diff --git a/src/bin/tools/createObjectThatThrowsIfAccessed.ts b/src/bin/tools/createObjectThatThrowsIfAccessed.ts new file mode 100644 index 00000000..ceb7022a --- /dev/null +++ b/src/bin/tools/createObjectThatThrowsIfAccessed.ts @@ -0,0 +1,90 @@ +const keyIsTrapped = "isTrapped_zSskDe9d"; + +export class AccessError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function createObjectThatThrowsIfAccessed(params?: { + debugMessage?: string; + isPropertyWhitelisted?: (prop: string | number | symbol) => boolean; +}): T { + const { debugMessage = "", isPropertyWhitelisted = () => false } = params ?? {}; + + const get: NonNullable["get"]> = (...args) => { + const [, prop] = args; + + if (isPropertyWhitelisted(prop)) { + return Reflect.get(...args); + } + + if (prop === keyIsTrapped) { + return true; + } + + throw new AccessError(`Cannot access ${String(prop)} yet ${debugMessage}`); + }; + + const trappedObject = new Proxy({} as any, { + get, + set: get + }); + + return trappedObject; +} + +export function createObjectThatThrowsIfAccessedFactory(params: { + isPropertyWhitelisted?: (prop: string | number | symbol) => boolean; +}) { + const { isPropertyWhitelisted } = params; + + return { + createObjectThatThrowsIfAccessed: (params?: { + debugMessage?: string; + }) => { + const { debugMessage } = params ?? {}; + + return createObjectThatThrowsIfAccessed({ + debugMessage, + isPropertyWhitelisted + }); + } + }; +} + +export function isObjectThatThrowIfAccessed(obj: object) { + return (obj as any)[keyIsTrapped] === true; +} + +export const THROW_IF_ACCESSED = { + __brand: "THROW_IF_ACCESSED" +}; + +export function createObjectWithSomePropertiesThatThrowIfAccessed< + T extends Record +>(obj: { [K in keyof T]: T[K] | typeof THROW_IF_ACCESSED }, debugMessage?: string): T { + return Object.defineProperties( + obj, + Object.fromEntries( + Object.entries(obj) + .filter(([, value]) => value === THROW_IF_ACCESSED) + .map(([key]) => { + const getAndSet = () => { + throw new AccessError( + `Cannot access ${key} yet ${debugMessage ?? ""}` + ); + }; + + const pd = { + get: getAndSet, + set: getAndSet, + enumerable: true + }; + + return [key, pd]; + }) + ) + ) as any; +}