import { type ThemeType, FALLBACK_LANGUAGE_TAG } from "../../shared/constants"; import { crawl } from "../../tools/crawl"; import { join as pathJoin, dirname as pathDirname } from "path"; import { symToStr } from "tsafe/symToStr"; import * as recast from "recast"; import * as babelParser from "@babel/parser"; import babelGenerate from "@babel/generator"; import * as babelTypes from "@babel/types"; import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile"; import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath"; import * as fs from "fs"; import { assert } from "tsafe/assert"; import type { BuildContext } from "../../shared/buildContext"; import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath"; export type BuildContextLike = { themeNames: string[]; themeSrcDirPath: string; }; assert(); export function generateMessageProperties(params: { buildContext: BuildContextLike; themeType: ThemeType; }): { languageTags: string[]; writeMessagePropertiesFiles: (params: { messageDirPath: string; themeName: string; }) => void; } { const { buildContext, themeType } = params; const baseMessagesDirPath = pathJoin( getThisCodebaseRootDirPath(), "src", themeType, "i18n", "messages_defaultSet" ); const messages_defaultSet_by_languageTag_defaultSet: { [languageTag_defaultSet: string]: Record; } = Object.fromEntries( fs .readdirSync(baseMessagesDirPath) .filter(basename => basename !== "index.ts" && basename !== "types.ts") .map(basename => ({ languageTag: basename.replace(/\.ts$/, ""), filePath: pathJoin(baseMessagesDirPath, basename) })) .map(({ languageTag, filePath }) => { const lines = fs.readFileSync(filePath).toString("utf8").split(/\r?\n/); let messagesJson = "{"; let isInDeclaration = false; for (const line of lines) { if (!isInDeclaration) { if (line.startsWith("const messages")) { isInDeclaration = true; } continue; } if (line.startsWith("}")) { messagesJson += "}"; break; } messagesJson += line; } const messages = JSON.parse(messagesJson) as Record; return [languageTag, messages]; }) ); const { i18nTsFilePath } = (() => { let files = crawl({ dirPath: pathJoin(buildContext.themeSrcDirPath, themeType), returnedPathsType: "absolute" }); files = files.filter(file => { const regex = /\.(js|ts|tsx)$/; return regex.test(file); }); files = files.sort((a, b) => { const regex = /\.i18n\.(ts|js|tsx)$/; const aIsI18nFile = regex.test(a); const bIsI18nFile = regex.test(b); return aIsI18nFile === bIsI18nFile ? 0 : aIsI18nFile ? -1 : 1; }); files = files.sort((a, b) => a.length - b.length); files = files.filter(file => fs.readFileSync(file).toString("utf8").includes("i18nBuilder") ); const i18nTsFilePath: string | undefined = files[0]; return { i18nTsFilePath }; })(); const i18nTsRoot = (() => { if (i18nTsFilePath === undefined) { return undefined; } const root = recastParseTs(i18nTsFilePath); return root; })(); const messages_defaultSet_by_languageTag_notInDefaultSet: | { [languageTag_notInDefaultSet: string]: Record } | undefined = (() => { if (i18nTsRoot === undefined) { return undefined; } let extraLanguageEntryByLanguageTag: Record< string, { label: string; path: string } > = {}; recast.visit(i18nTsRoot, { visitCallExpression: function (path) { const node = path.node; // Check if the callee is a MemberExpression with property 'withExtraLanguages' if ( node.callee.type === "MemberExpression" && node.callee.property.type === "Identifier" && node.callee.property.name === "withExtraLanguages" ) { const arg = node.arguments[0]; if (arg && arg.type === "ObjectExpression") { // Iterate over the properties of the object arg.properties.forEach(prop => { if ( prop.type === "ObjectProperty" && prop.key.type === "Identifier" ) { const lang = prop.key.name; const value = prop.value; if (value.type === "ObjectExpression") { let label: string | undefined = undefined; let pathStr: string | undefined = undefined; // Iterate over the properties of the language object value.properties.forEach(p => { if ( p.type === "ObjectProperty" && p.key.type === "Identifier" ) { if ( p.key.name === "label" && p.value.type === "StringLiteral" ) { label = p.value.value; } if ( p.key.name === "getMessages" && (p.value.type === "ArrowFunctionExpression" || p.value.type === "FunctionExpression") ) { // Extract the import path from the function body const body = p.value.body; if ( body.type === "CallExpression" && body.callee.type === "Import" ) { const importArg = body.arguments[0]; if ( importArg.type === "StringLiteral" ) { pathStr = importArg.value; } } else if ( body.type === "BlockStatement" ) { // If the function body is a block (e.g., function with braces {}) // Look for return statement body.body.forEach(statement => { if ( statement.type === "ReturnStatement" && statement.argument && statement.argument.type === "CallExpression" && statement.argument.callee .type === "Import" ) { const importArg = statement.argument .arguments[0]; if ( importArg.type === "StringLiteral" ) { pathStr = importArg.value; } } }); } } } }); if (label && pathStr) { extraLanguageEntryByLanguageTag[lang] = { label, path: pathStr }; } } } }); } return false; // Stop traversing this path } this.traverse(path); // Continue traversing other paths } }); const messages_defaultSet_by_languageTag_notInDefaultSet = Object.fromEntries( Object.entries(extraLanguageEntryByLanguageTag).map( ([languageTag, { path: relativePathWithoutExt }]) => [ languageTag, (() => { const filePath = getAbsoluteAndInOsFormatPath({ pathIsh: relativePathWithoutExt.endsWith(".ts") ? relativePathWithoutExt : `${relativePathWithoutExt}.ts`, cwd: pathDirname(i18nTsFilePath) }); const root = recastParseTs(filePath); let declarationCode: string | undefined = ""; recast.visit(root, { visitVariableDeclarator: function (path) { const node = path.node; // Check if the variable name is 'messages' if ( node.id.type === "Identifier" && node.id.name === "messages" ) { // Ensure there is an initializer if (node.init) { // Generate code from the initializer, preserving comments declarationCode = recast .print(node.init) .code.replace(/}.*$/, "}"); } return false; // Stop traversing this path } this.traverse(path); // Continue traversing other paths } }); assert( declarationCode !== undefined, `${filePath} does not contain a 'messages' variable declaration` ); let messages: Record = {}; try { eval(`${symToStr({ messages })} = ${declarationCode};`); } catch { throw new Error( `The declaration of 'message' in ${filePath} cannot be statically evaluated: ${declarationCode}` ); } return messages; })() ] ) ); return messages_defaultSet_by_languageTag_notInDefaultSet; })(); const messages_defaultSet_by_languageTag = { ...messages_defaultSet_by_languageTag_defaultSet, ...messages_defaultSet_by_languageTag_notInDefaultSet }; const messages_themeDefined_by_languageTag: | { [languageTag: string]: | Record> | undefined; } | undefined = (() => { if (i18nTsRoot === undefined) { return undefined; } let firstArgumentCode: string | undefined = undefined; recast.visit(i18nTsRoot, { visitCallExpression: function (path) { const node = path.node; if ( node.callee.type === "MemberExpression" && node.callee.property.type === "Identifier" && node.callee.property.name === "withCustomTranslations" ) { firstArgumentCode = babelGenerate(node.arguments[0] as any).code; return false; } this.traverse(path); } }); if (firstArgumentCode === undefined) { return undefined; } let messages_themeDefined_by_languageTag: { [languageTag: string]: Record>; } = {}; try { eval( `${symToStr({ messages_themeDefined_by_languageTag })} = ${firstArgumentCode}` ); } catch { console.warn( [ "WARNING: The argument of withCustomTranslations can't be statically evaluated!", "This needs to be fixed refer to the documentation: https://docs.keycloakify.dev/i18n", firstArgumentCode ].join(" ") ); return undefined; } return messages_themeDefined_by_languageTag; })(); const languageTags = Object.keys(messages_defaultSet_by_languageTag); return { languageTags, writeMessagePropertiesFiles: ({ messageDirPath, themeName }) => { for (const languageTag of languageTags) { const messages = { ...messages_defaultSet_by_languageTag[languageTag] }; add_theme_defined_messages: { if (messages_themeDefined_by_languageTag === undefined) { break add_theme_defined_messages; } let messages_themeDefined = messages_themeDefined_by_languageTag[languageTag]; if (messages_themeDefined === undefined) { messages_themeDefined = messages_themeDefined_by_languageTag[FALLBACK_LANGUAGE_TAG]; } if (messages_themeDefined === undefined) { messages_themeDefined = messages_themeDefined_by_languageTag[ Object.keys(messages_themeDefined_by_languageTag)[0] ]; } if (messages_themeDefined === undefined) { break add_theme_defined_messages; } for (const [key, messageOrMessageByThemeName] of Object.entries( messages_themeDefined )) { const message = (() => { if (typeof messageOrMessageByThemeName === "string") { return messageOrMessageByThemeName; } const message = messageOrMessageByThemeName[themeName]; assert(message !== undefined); return message; })(); messages[key] = message; } } const propertiesFileSource = [ "", ...Object.entries(messages).map( ([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}` ), "" ].join("\n"); fs.mkdirSync(messageDirPath, { recursive: true }); fs.writeFileSync( pathJoin(messageDirPath, `messages_${languageTag}.properties`), Buffer.from(propertiesFileSource, "utf8") ); } } }; } function recastParseTs(filePath: string): recast.types.ASTNode { return recast.parse(fs.readFileSync(filePath).toString("utf8"), { parser: { parse: (code: string) => babelParser.parse(code, { sourceType: "module", plugins: ["typescript"] }), generator: babelGenerate, types: babelTypes } }); }