Correctly generate i18n messages for admin UI
This commit is contained in:
parent
c39c450e90
commit
326411ca5d
@ -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<string, Buffer>;
|
||||
}
|
||||
> = {};
|
||||
|
||||
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<Buffer>({
|
||||
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;
|
||||
|
@ -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",
|
||||
|
@ -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 [`<!--`, ...lines.map(line => ` ${line}`), `-->`].join("\n");
|
||||
}
|
||||
|
||||
if (fileRelativePath.endsWith(".svg")) {
|
||||
return [
|
||||
`<!--`,
|
||||
...lines.map(line => ` ${line.replace("--file", "-f")}`),
|
||||
`-->`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (fileRelativePath.endsWith(".properties")) {
|
||||
return lines.map(line => `# ${line}`).join("\n");
|
||||
}
|
||||
|
90
src/bin/tools/createObjectThatThrowsIfAccessed.ts
Normal file
90
src/bin/tools/createObjectThatThrowsIfAccessed.ts
Normal file
@ -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<T extends object>(params?: {
|
||||
debugMessage?: string;
|
||||
isPropertyWhitelisted?: (prop: string | number | symbol) => boolean;
|
||||
}): T {
|
||||
const { debugMessage = "", isPropertyWhitelisted = () => false } = params ?? {};
|
||||
|
||||
const get: NonNullable<ProxyHandler<T>["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<T>({} as any, {
|
||||
get,
|
||||
set: get
|
||||
});
|
||||
|
||||
return trappedObject;
|
||||
}
|
||||
|
||||
export function createObjectThatThrowsIfAccessedFactory(params: {
|
||||
isPropertyWhitelisted?: (prop: string | number | symbol) => boolean;
|
||||
}) {
|
||||
const { isPropertyWhitelisted } = params;
|
||||
|
||||
return {
|
||||
createObjectThatThrowsIfAccessed: <T extends object>(params?: {
|
||||
debugMessage?: string;
|
||||
}) => {
|
||||
const { debugMessage } = params ?? {};
|
||||
|
||||
return createObjectThatThrowsIfAccessed<T>({
|
||||
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<string, unknown>
|
||||
>(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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user