From b0b6b994ed46ab834071f9aa1d91c991e29659a5 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 22 Sep 2024 04:39:24 +0200 Subject: [PATCH] Almost done, left to extract the extra language resources --- .../generateMessageProperties.ts | 333 ++++++++++++------ .../generateResources/generateResources.ts | 5 +- .../generateResourcesForMainTheme.ts | 87 +++-- .../generateResourcesForThemeVariant.ts | 24 +- src/login/i18n/noJsx/getI18n.tsx | 2 +- stories/login/gpt.md | 94 +++++ 6 files changed, 391 insertions(+), 154 deletions(-) create mode 100644 stories/login/gpt.md diff --git a/src/bin/keycloakify/generateResources/generateMessageProperties.ts b/src/bin/keycloakify/generateResources/generateMessageProperties.ts index 512bbaad..60f727f5 100644 --- a/src/bin/keycloakify/generateResources/generateMessageProperties.ts +++ b/src/bin/keycloakify/generateResources/generateMessageProperties.ts @@ -10,12 +10,26 @@ import { escapeStringForPropertiesFile } from "../../tools/escapeStringForProper import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath"; import * as fs from "fs"; import { assert } from "tsafe/assert"; +import type { BuildContext } from "../../shared/buildContext"; + +export type BuildContextLike = { + themeNames: string[]; + themeSrcDirPath: string; +}; + +assert(); export function generateMessageProperties(params: { - themeSrcDirPath: string; + buildContext: BuildContextLike; themeType: ThemeType; -}): { languageTag: string; propertiesFileSource: string }[] { - const { themeSrcDirPath, themeType } = params; +}): { + languageTags: string[]; + writeMessagePropertiesFiles: (params: { + messageDirPath: string; + themeName: string; + }) => void; +} { + const { buildContext, themeType } = params; const baseMessagesDirPath = pathJoin( getThisCodebaseRootDirPath(), @@ -25,51 +39,49 @@ export function generateMessageProperties(params: { "messages_defaultSet" ); - const baseMessageBundle: { [languageTag: 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/); + 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 messagesJson = "{"; - let isInDeclaration = false; + let isInDeclaration = false; - for (const line of lines) { - if (!isInDeclaration) { - if (line.startsWith("const messages")) { - isInDeclaration = true; - } - - continue; + for (const line of lines) { + if (!isInDeclaration) { + if (line.startsWith("const messages")) { + isInDeclaration = true; } - if (line.startsWith("}")) { - messagesJson += "}"; - break; - } - - messagesJson += line; + continue; } - const messages = JSON.parse(messagesJson) as Record; + if (line.startsWith("}")) { + messagesJson += "}"; + break; + } - return [languageTag, messages]; - }) - ); + messagesJson += line; + } + + const messages = JSON.parse(messagesJson) as Record; + + return [languageTag, messages]; + }) + ); const { i18nTsFilePath } = (() => { let files = crawl({ - dirPath: pathJoin(themeSrcDirPath, themeType), + dirPath: pathJoin(buildContext.themeSrcDirPath, themeType), returnedPathsType: "absolute" }); @@ -88,7 +100,7 @@ export function generateMessageProperties(params: { files = files.sort((a, b) => a.length - b.length); files = files.filter(file => - fs.readFileSync(file).toString("utf8").includes("createUseI18n(") + fs.readFileSync(file).toString("utf8").includes("i18nBuilder") ); const i18nTsFilePath: string | undefined = files[0]; @@ -96,13 +108,13 @@ export function generateMessageProperties(params: { return { i18nTsFilePath }; })(); - const messageBundle: { [languageTag: string]: Record } | undefined = - (() => { - if (i18nTsFilePath === undefined) { - return undefined; - } - - const root = recast.parse(fs.readFileSync(i18nTsFilePath).toString("utf8"), { + const i18nTsRoot = (() => { + if (i18nTsFilePath === undefined) { + return undefined; + } + const root: recast.types.ASTNode = recast.parse( + fs.readFileSync(i18nTsFilePath).toString("utf8"), + { parser: { parse: (code: string) => babelParser.parse(code, { @@ -112,81 +124,176 @@ export function generateMessageProperties(params: { generator: babelGenerate, types: babelTypes } - }); + } + ); + return root; + })(); - let messageBundleDeclarationTsCode: string | undefined = undefined; + const messages_defaultSet_by_languageTag_notInDefaultSet: + | { [languageTag_notInDefaultSet: string]: Record } + | undefined = (() => { + if (i18nTsRoot === undefined) { + return undefined; + } - recast.visit(root, { - visitCallExpression: function (path) { - if ( - path.node.callee.type === "Identifier" && - path.node.callee.name === "createUseI18n" - ) { - messageBundleDeclarationTsCode = babelGenerate( - path.node.arguments[0] as any - ).code; - return false; + 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 === "withExtraLanguages" + ) { + firstArgumentCode = babelGenerate(node.arguments[0] as any).code; + return false; + } + + this.traverse(path); + } + }); + + if (firstArgumentCode === undefined) { + return undefined; + } + + //todo + + //TODO + return {}; + })(); + + 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: Make sure the messageBundle your provided as argument of createUseI18n can be statically evaluated.", + "This is important because we need to put your i18n messages in messages_*.properties files", + "or they won't be available server side.", + "\n", + "The following code could not be evaluated:", + "\n", + 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; } - this.traverse(path); + 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; + } } - }); - assert(messageBundleDeclarationTsCode !== undefined); + const propertiesFileSource = [ + "", + ...Object.entries(messages).map( + ([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}` + ), + "" + ].join("\n"); - let messageBundle: { - [languageTag: string]: Record; - } = {}; - - try { - eval( - `${symToStr({ messageBundle })} = ${messageBundleDeclarationTsCode}` - ); - } catch { - console.warn( - [ - "WARNING: Make sure the messageBundle your provided as argument of createUseI18n can be statically evaluated.", - "This is important because we need to put your i18n messages in messages_*.properties files", - "or they won't be available server side.", - "\n", - "The following code could not be evaluated:", - "\n", - messageBundleDeclarationTsCode - ].join(" ") + fs.writeFileSync( + pathJoin(messageDirPath, `messages_${languageTag}.properties`), + Buffer.from(propertiesFileSource, "utf8") ); } - - return messageBundle; - })(); - - const mergedMessageBundle: { [languageTag: string]: Record } = - Object.fromEntries( - Object.entries(baseMessageBundle).map(([languageTag, messages]) => [ - languageTag, - { - ...messages, - ...(messageBundle === undefined - ? {} - : messageBundle[languageTag] ?? - messageBundle[FALLBACK_LANGUAGE_TAG] ?? - messageBundle[Object.keys(messageBundle)[0]] ?? - {}) - } - ]) - ); - - const messageProperties: { languageTag: string; propertiesFileSource: string }[] = - Object.entries(mergedMessageBundle).map(([languageTag, messages]) => ({ - languageTag, - propertiesFileSource: [ - "", - ...(themeType !== "account" ? ["parent=base"] : []), - ...Object.entries(messages).map( - ([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}` - ), - "" - ].join("\n") - })); - - return messageProperties; + } + }; } diff --git a/src/bin/keycloakify/generateResources/generateResources.ts b/src/bin/keycloakify/generateResources/generateResources.ts index a5b91ba3..0c020a2d 100644 --- a/src/bin/keycloakify/generateResources/generateResources.ts +++ b/src/bin/keycloakify/generateResources/generateResources.ts @@ -26,7 +26,7 @@ export async function generateResources(params: { rmSync(resourcesDirPath, { recursive: true }); } - await generateResourcesForMainTheme({ + const { writeMessagePropertiesFiles } = await generateResourcesForMainTheme({ resourcesDirPath, themeName, buildContext @@ -36,7 +36,8 @@ export async function generateResources(params: { generateResourcesForThemeVariant({ resourcesDirPath, themeName, - themeVariantName + themeVariantName, + writeMessagePropertiesFiles }); } } diff --git a/src/bin/keycloakify/generateResources/generateResourcesForMainTheme.ts b/src/bin/keycloakify/generateResources/generateResourcesForMainTheme.ts index bad77af6..51a5b312 100644 --- a/src/bin/keycloakify/generateResources/generateResourcesForMainTheme.ts +++ b/src/bin/keycloakify/generateResources/generateResourcesForMainTheme.ts @@ -17,7 +17,10 @@ import type { BuildContext } from "../../shared/buildContext"; import { assert, type Equals } from "tsafe/assert"; import { readFieldNameUsage } from "./readFieldNameUsage"; import { readExtraPagesNames } from "./readExtraPageNames"; -import { generateMessageProperties } from "./generateMessageProperties"; +import { + generateMessageProperties, + type BuildContextLike as BuildContextLike_generateMessageProperties +} from "./generateMessageProperties"; import { rmSync } from "../../tools/fs.rmSync"; import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion"; import { @@ -29,24 +32,30 @@ import { escapeStringForPropertiesFile } from "../../tools/escapeStringForProper import * as child_process from "child_process"; import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath"; -export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode & { - extraThemeProperties: string[] | undefined; - projectDirPath: string; - projectBuildDirPath: string; - environmentVariables: { name: string; default: string }[]; - implementedThemeTypes: BuildContext["implementedThemeTypes"]; - themeSrcDirPath: string; - bundler: "vite" | "webpack"; - packageJsonFilePath: string; -}; +export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode & + BuildContextLike_generateMessageProperties & { + extraThemeProperties: string[] | undefined; + projectDirPath: string; + projectBuildDirPath: string; + environmentVariables: { name: string; default: string }[]; + implementedThemeTypes: BuildContext["implementedThemeTypes"]; + themeSrcDirPath: string; + bundler: "vite" | "webpack"; + packageJsonFilePath: string; + }; assert(); export async function generateResourcesForMainTheme(params: { + buildContext: BuildContextLike; themeName: string; resourcesDirPath: string; - buildContext: BuildContextLike; -}): Promise { +}): Promise<{ + writeMessagePropertiesFiles: (params: { + getMessageDirPath: (params: { themeType: ThemeType }) => string; + themeName: string; + }) => void; +}> { const { themeName, resourcesDirPath, buildContext } = params; const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => { @@ -54,6 +63,10 @@ export async function generateResourcesForMainTheme(params: { return pathJoin(resourcesDirPath, "theme", themeName, themeType); }; + const writeMessagePropertiesFilesByThemeType: Partial< + Record void> + > = {}; + for (const themeType of ["login", "account"] as const) { if (!buildContext.implementedThemeTypes[themeType].isImplemented) { continue; @@ -187,30 +200,27 @@ export async function generateResourcesForMainTheme(params: { ); }); + let languageTags: string[] | undefined = undefined; + i18n_messages_generation: { if (isForAccountSpa) { break i18n_messages_generation; } - generateMessageProperties({ - themeSrcDirPath: buildContext.themeSrcDirPath, + const wrap = generateMessageProperties({ + buildContext, themeType - }).forEach(({ languageTag, propertiesFileSource }) => { - const messagesDirPath = pathJoin(themeTypeDirPath, "messages"); + }); - fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), { - recursive: true - }); + languageTags = wrap.languageTags; + const { writeMessagePropertiesFiles } = wrap; - const propertiesFilePath = pathJoin( - messagesDirPath, - `messages_${languageTag}.properties` - ); + writeMessagePropertiesFilesByThemeType[themeType] = + writeMessagePropertiesFiles; - fs.writeFileSync( - propertiesFilePath, - Buffer.from(propertiesFileSource, "utf8") - ); + writeMessagePropertiesFiles({ + messageDirPath: pathJoin(themeTypeDirPath, "messages"), + themeName }); } @@ -280,7 +290,10 @@ export async function generateResourcesForMainTheme(params: { ...(buildContext.extraThemeProperties ?? []), ...buildContext.environmentVariables.map( ({ name, default: defaultValue }) => - `${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}` + `${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`, + ...(languageTags === undefined + ? [] + : `locales=${languageTags.join(",")}`) ) ].join("\n\n"), "utf8" @@ -338,4 +351,20 @@ export async function generateResourcesForMainTheme(params: { getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes }); } + + return { + writeMessagePropertiesFiles: ({ getMessageDirPath, themeName }) => { + objectEntries(writeMessagePropertiesFilesByThemeType).forEach( + ([themeType, writeMessagePropertiesFiles]) => { + if (writeMessagePropertiesFiles === undefined) { + return; + } + writeMessagePropertiesFiles({ + messageDirPath: getMessageDirPath({ themeType }), + themeName + }); + } + ); + } + }; } diff --git a/src/bin/keycloakify/generateResources/generateResourcesForThemeVariant.ts b/src/bin/keycloakify/generateResources/generateResourcesForThemeVariant.ts index 1a8c8666..dd3203c0 100644 --- a/src/bin/keycloakify/generateResources/generateResourcesForThemeVariant.ts +++ b/src/bin/keycloakify/generateResources/generateResourcesForThemeVariant.ts @@ -1,27 +1,27 @@ import { join as pathJoin, extname as pathExtname, sep as pathSep } from "path"; import { transformCodebase } from "../../tools/transformCodebase"; -import type { BuildContext } from "../../shared/buildContext"; import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes"; import { assert } from "tsafe/assert"; - -export type BuildContextLike = { - keycloakifyBuildDirPath: string; -}; - -assert(); +import type { ThemeType } from "../../shared/constants"; export function generateResourcesForThemeVariant(params: { resourcesDirPath: string; themeName: string; themeVariantName: string; + writeMessagePropertiesFiles: (params: { + getMessageDirPath: (params: { themeType: ThemeType }) => string; + themeName: string; + }) => void; }) { - const { resourcesDirPath, themeName, themeVariantName } = params; + const { resourcesDirPath, themeName, themeVariantName, writeMessagePropertiesFiles } = + params; const mainThemeDirPath = pathJoin(resourcesDirPath, "theme", themeName); + const themeVariantDirPath = pathJoin(mainThemeDirPath, "..", themeVariantName); transformCodebase({ srcDirPath: mainThemeDirPath, - destDirPath: pathJoin(mainThemeDirPath, "..", themeVariantName), + destDirPath: themeVariantDirPath, transformSourceCode: ({ fileRelativePath, sourceCode }) => { if ( pathExtname(fileRelativePath) === ".ftl" && @@ -67,4 +67,10 @@ export function generateResourcesForThemeVariant(params: { return newMetaInfKeycloakTheme; } }); + + writeMessagePropertiesFiles({ + getMessageDirPath: ({ themeType }) => + pathJoin(themeVariantDirPath, themeType, "messages"), + themeName: themeVariantName + }); } diff --git a/src/login/i18n/noJsx/getI18n.tsx b/src/login/i18n/noJsx/getI18n.tsx index 42fd5c4e..dee8a295 100644 --- a/src/login/i18n/noJsx/getI18n.tsx +++ b/src/login/i18n/noJsx/getI18n.tsx @@ -126,7 +126,7 @@ export function createGetI18n< let label = labelBySupportedLanguageTag[languageTag]; - if (label === undefined) { + if (label === undefined || label === "" || label === languageTag) { assert(is>(languageTag)); const entry = extraLanguageTranslations[languageTag]; diff --git a/stories/login/gpt.md b/stories/login/gpt.md new file mode 100644 index 00000000..b38567b2 --- /dev/null +++ b/stories/login/gpt.md @@ -0,0 +1,94 @@ +Hello GPT, + +So, I'm using recast in a node script to parse a typescript source file and extract the part that I'm intrested in. + +Example of the source file: + +```ts +import { createUseI18n } from "keycloakify/login"; + +export const { useI18n, ofTypeI18n } = createUseI18n({ + en: { + myCustomMessage: "My custom message" + }, + fr: { + myCustomMessage: "Mon message personnalisé" + } +}); + +export type I18n = typeof ofTypeI18n; +``` + +The string that I want to extract from this source file is: + +```raw +{ + en: { + myCustomMessage: "My custom message" + }, + fr: { + myCustomMessage: "Mon message personnalisé" + } +} +``` + +This is my script: + +```ts +const root = recast.parse(fs.readFileSync(i18nTsFilePath).toString("utf8"), { + parser: { + parse: (code: string) => + babelParser.parse(code, { + sourceType: "module", + plugins: ["typescript"] + }), + generator: babelGenerate, + types: babelTypes + } +}); + +let messageBundleDeclarationTsCode: string | undefined = undefined; + +recast.visit(root, { + visitCallExpression: function (path) { + if ( + path.node.callee.type === "Identifier" && + path.node.callee.name === "createUseI18n" + ) { + messageBundleDeclarationTsCode = babelGenerate( + path.node.arguments[0] as any + ).code; + return false; + } + + this.traverse(path); + } +}); + +// Here messageBundleDeclarationTsCode contains the string I want +``` + +It works, but now, the API has changed. The source file looks like this: + +```ts +import { i18nBuilder } from "keycloakify/login/i18n"; + +const { useI18n, ofTypeI18n } = i18nBuilder + .withThemeName<"my-theme-1" | "my-theme-2">() + .withCustomTranslations({ + en: { + myCustomMessage: "My custom message" + }, + fr: { + myCustomMessage: "Mon message personnalisé" + } + }) + .build(); + +type I18n = typeof ofTypeI18n; + +export { useI18n, type I18n }; +``` + +Can you modify the script to extract the string taking into account the change that have been made to the source file? +(I need to extract the argument that is passed to the `withCustomTranslations` method)