Stable i18n messages across Keycloak versions

This commit is contained in:
Joseph Garrone
2024-06-22 20:12:02 +02:00
parent e99fdb8561
commit 319dcc0d15
2 changed files with 132 additions and 116 deletions

View File

@ -65,11 +65,14 @@ async function main() {
fs fs
.readFileSync(pathJoin(baseThemeDirPath, filePath)) .readFileSync(pathJoin(baseThemeDirPath, filePath))
.toString("utf8") .toString("utf8")
) ) as Record<string, string>
).map(([key, value]: any) => [ )
key === "locale_pt_BR" ? "locale_pt-BR" : key, .map(([key, value]) => [key, value.replace(/''/g, "'")])
value.replace(/''/g, "'") .map(([key, value]) => [
]) key === "locale_pt_BR" ? "locale_pt-BR" : key,
value
])
.map(([key, value]) => [key, key === "termsText" ? "" : value])
); );
}); });
} }

View File

@ -1,9 +1,7 @@
import { type ThemeType, fallbackLanguageTag } from "../../shared/constants"; import { type ThemeType, fallbackLanguageTag } from "../../shared/constants";
import { crawl } from "../../tools/crawl"; import { crawl } from "../../tools/crawl";
import { join as pathJoin } from "path"; import { join as pathJoin } from "path";
import { readFileSync } from "fs";
import { symToStr } from "tsafe/symToStr"; import { symToStr } from "tsafe/symToStr";
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
import * as recast from "recast"; import * as recast from "recast";
import * as babelParser from "@babel/parser"; import * as babelParser from "@babel/parser";
import babelGenerate from "@babel/generator"; import babelGenerate from "@babel/generator";
@ -11,6 +9,7 @@ import * as babelTypes from "@babel/types";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile"; import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath"; import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
import * as fs from "fs"; import * as fs from "fs";
import { assert } from "tsafe/assert";
export function generateMessageProperties(params: { export function generateMessageProperties(params: {
themeSrcDirPath: string; themeSrcDirPath: string;
@ -18,32 +17,92 @@ export function generateMessageProperties(params: {
}): { languageTag: string; propertiesFileSource: string }[] { }): { languageTag: string; propertiesFileSource: string }[] {
const { themeSrcDirPath, themeType } = params; const { themeSrcDirPath, themeType } = params;
let files = crawl({ const baseMessagesDirPath = pathJoin(
dirPath: pathJoin(themeSrcDirPath, themeType), getThisCodebaseRootDirPath(),
returnedPathsType: "absolute" "src",
}); themeType,
"i18n",
files = files.filter(file => { "baseMessages"
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 =>
readFileSync(file).toString("utf8").includes("createUseI18n")
); );
const messageBundles = files const baseMessageBundle: { [languageTag: string]: Record<string, string> } =
.map(file => { Object.fromEntries(
const root = recast.parse(readFileSync(file).toString("utf8"), { fs
.readdirSync(baseMessagesDirPath)
.filter(baseName => baseName !== "index.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<string, string>;
return [languageTag, messages];
})
);
const { i18nTsFilePath } = (() => {
let files = crawl({
dirPath: pathJoin(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("createUseI18n(")
);
const i18nTsFilePath: string | undefined = files[0];
return { i18nTsFilePath };
})();
const messageBundle: { [languageTag: string]: Record<string, string> } | undefined =
(() => {
if (i18nTsFilePath === undefined) {
return undefined;
}
const root = recast.parse(fs.readFileSync(i18nTsFilePath).toString("utf8"), {
parser: { parser: {
parse: (code: string) => parse: (code: string) =>
babelParser.parse(code, { babelParser.parse(code, {
@ -55,7 +114,7 @@ export function generateMessageProperties(params: {
} }
}); });
const codes: string[] = []; let messageBundleDeclarationTsCode: string | undefined = undefined;
recast.visit(root, { recast.visit(root, {
visitCallExpression: function (path) { visitCallExpression: function (path) {
@ -63,117 +122,71 @@ export function generateMessageProperties(params: {
path.node.callee.type === "Identifier" && path.node.callee.type === "Identifier" &&
path.node.callee.name === "createUseI18n" path.node.callee.name === "createUseI18n"
) { ) {
codes.push(babelGenerate(path.node.arguments[0] as any).code); messageBundleDeclarationTsCode = babelGenerate(
path.node.arguments[0] as any
).code;
return false;
} }
this.traverse(path); this.traverse(path);
} }
}); });
return codes; assert(messageBundleDeclarationTsCode !== undefined);
})
.flat()
.map(code => {
let messageBundle: { let messageBundle: {
[languageTag: string]: Record<string, string>; [languageTag: string]: Record<string, string>;
} = {}; } = {};
try { try {
eval(`${symToStr({ messageBundle })} = ${code}`); eval(
`${symToStr({ messageBundle })} = ${messageBundleDeclarationTsCode}`
);
} catch { } catch {
console.warn( console.warn(
[ [
"WARNING: Make sure that the first argument of createUseI18n can be evaluated in a javascript", "WARNING: Make sure the messageBundle your provided as argument of createUseI18n can be statically evaluated.",
"runtime where only the node globals are available.",
"This is important because we need to put your i18n messages in messages_*.properties files", "This is important because we need to put your i18n messages in messages_*.properties files",
"or they won't be available server side.", "or they won't be available server side.",
"\n", "\n",
"The following code could not be evaluated:", "The following code could not be evaluated:",
"\n", "\n",
code messageBundleDeclarationTsCode
].join(" ") ].join(" ")
); );
} }
return messageBundle; return messageBundle;
}); })();
const languageTags_messageBundle = messageBundles const mergedMessageBundle: { [languageTag: string]: Record<string, string> } =
.map(extraMessage => Object.keys(extraMessage)) Object.fromEntries(
.flat() Object.entries(baseMessageBundle).map(([languageTag, messages]) => [
.reduce(...removeDuplicates<string>()); languageTag,
{
const keyValueMapByLanguageTag: Record<string, Record<string, string>> = {}; ...messages,
...(messageBundle === undefined
languageTags_messageBundle.forEach(languageTag_messageBundle => { ? {}
const keyValueMap: Record<string, string> = { : messageBundle[languageTag] ??
termsText: "" messageBundle[fallbackLanguageTag] ??
}; messageBundle[Object.keys(messageBundle)[0]] ??
{})
for (const messageBundle of messageBundles) {
const keyValueMap_i = messageBundle[languageTag_messageBundle];
if (keyValueMap_i === undefined) {
continue;
}
for (const [key, value] of Object.entries(keyValueMap_i)) {
if (key !== "termsText" && keyValueMap[key] !== undefined) {
console.warn(
[
"WARNING: The following key is defined multiple times:",
"\n",
key,
"\n",
"The following value will be ignored:",
"\n",
value,
"\n",
"The following value was already defined:",
"\n",
keyValueMap[key]
].join(" ")
);
continue;
} }
])
keyValueMap[key] = value;
}
}
keyValueMapByLanguageTag[languageTag_messageBundle] = keyValueMap;
});
fs.readdirSync(
pathJoin(getThisCodebaseRootDirPath(), "src", themeType, "i18n", "baseMessages")
)
.filter(baseName => baseName !== "index.ts")
.map(baseName => baseName.replace(/\.ts$/, ""))
.filter(languageTag => !languageTags_messageBundle.includes(languageTag))
.forEach(
languageTag_noMessageBundle =>
(keyValueMapByLanguageTag[languageTag_noMessageBundle] =
keyValueMapByLanguageTag[fallbackLanguageTag] ??
keyValueMapByLanguageTag[
Object.keys(keyValueMapByLanguageTag)[0]
] ?? {
termsText: ""
})
); );
const out: { languageTag: string; propertiesFileSource: string }[] = []; const messageProperties: { languageTag: string; propertiesFileSource: string }[] =
Object.entries(mergedMessageBundle).map(([languageTag, messages]) => ({
for (const [languageTag, keyValueMap] of Object.entries(keyValueMapByLanguageTag)) {
const propertiesFileSource = Object.entries(keyValueMap)
.map(([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`)
.join("\n");
out.push({
languageTag, languageTag,
propertiesFileSource: ["", "parent=base", "", propertiesFileSource, ""].join( propertiesFileSource: [
"\n" "",
) ...(themeType !== "account" ? ["parent=base"] : []),
}); ...Object.entries(messages).map(
} ([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`
),
""
].join("\n")
}));
return out; return messageProperties;
} }