Support incorporating theme native theme, with theme variant support #733

This commit is contained in:
Joseph Garrone 2025-01-04 23:24:44 +01:00
parent c33c315120
commit 4845d7c32d
2 changed files with 361 additions and 236 deletions

View File

@ -41,6 +41,7 @@ import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPa
import propertiesParser from "properties-parser"; import propertiesParser from "properties-parser";
import { createObjectThatThrowsIfAccessed } from "../../tools/createObjectThatThrowsIfAccessed"; import { createObjectThatThrowsIfAccessed } from "../../tools/createObjectThatThrowsIfAccessed";
import { listInstalledModules } from "../../tools/listInstalledModules"; import { listInstalledModules } from "../../tools/listInstalledModules";
import { isInside } from "../../tools/isInside";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode & export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_generateMessageProperties & { BuildContextLike_generateMessageProperties & {
@ -78,12 +79,23 @@ export async function generateResources(params: {
}; };
const writeMessagePropertiesFilesByThemeType: Partial< const writeMessagePropertiesFilesByThemeType: Partial<
Record<ThemeType, (params: { messageDirPath: string; themeName: string }) => void> Record<
ThemeType | "email",
(params: { messageDirPath: string; themeName: string }) => void
>
> = {}; > = {};
for (const themeType of THEME_TYPES) { for (const themeType of [...THEME_TYPES, "email"] as const) {
if (!buildContext.implementedThemeTypes[themeType].isImplemented) { let isNative: boolean;
continue;
{
const v = buildContext.implementedThemeTypes[themeType];
if (!v.isImplemented && !v.isImplemented_native) {
continue;
}
isNative = !v.isImplemented && v.isImplemented_native;
} }
const getAccountThemeType = () => { const getAccountThemeType = () => {
@ -102,12 +114,18 @@ export async function generateResources(params: {
return getAccountThemeType() === "Single-Page"; return getAccountThemeType() === "Single-Page";
case "admin": case "admin":
return true; return true;
case "email":
return false;
} }
})(); })();
const themeTypeDirPath = getThemeTypeDirPath({ themeName, themeType }); const themeTypeDirPath = getThemeTypeDirPath({ themeName, themeType });
apply_replacers_and_move_to_theme_resources: { apply_replacers_and_move_to_theme_resources: {
if (isNative) {
break apply_replacers_and_move_to_theme_resources;
}
const destDirPath = pathJoin( const destDirPath = pathJoin(
themeTypeDirPath, themeTypeDirPath,
"resources", "resources",
@ -191,59 +209,93 @@ export async function generateResources(params: {
}); });
} }
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({ generate_ftl_files: {
themeName, if (isNative) {
indexHtmlCode: fs break generate_ftl_files;
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html")) }
.toString("utf8"),
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: isSpa
? []
: (assert(themeType !== "admin"),
readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}))
});
[ assert(themeType !== "email");
...(() => {
switch (themeType) { const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
case "login": themeName,
return LOGIN_THEME_PAGE_IDS; indexHtmlCode: fs
case "account": .readFileSync(
return getAccountThemeType() === "Single-Page" pathJoin(buildContext.projectBuildDirPath, "index.html")
? ["index.ftl"] )
: ACCOUNT_THEME_PAGE_IDS; .toString("utf8"),
case "admin": buildContext,
return ["index.ftl"]; keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: isSpa
? []
: (assert(themeType !== "admin"),
readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}))
});
[
...(() => {
switch (themeType) {
case "login":
return LOGIN_THEME_PAGE_IDS;
case "account":
return getAccountThemeType() === "Single-Page"
? ["index.ftl"]
: ACCOUNT_THEME_PAGE_IDS;
case "admin":
return ["index.ftl"];
}
})(),
...(isSpa
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
);
});
}
copy_native_theme: {
if (!isNative) {
break copy_native_theme;
}
const dirPath = pathJoin(buildContext.themeSrcDirPath, themeType);
transformCodebase({
srcDirPath: dirPath,
destDirPath: getThemeTypeDirPath({ themeName, themeType }),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (isInside({ dirPath: "messages", filePath: fileRelativePath })) {
return undefined;
}
return { modifiedSourceCode: sourceCode };
} }
})(), });
...(isSpa }
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
);
});
let languageTags: string[] | undefined = undefined; let languageTags: string[] | undefined = undefined;
i18n_multi_page: { i18n_multi_page: {
if (isNative) {
break i18n_multi_page;
}
if (isSpa) { if (isSpa) {
break i18n_multi_page; break i18n_multi_page;
} }
assert(themeType !== "admin"); assert(themeType !== "admin" && themeType !== "email");
const wrap = generateMessageProperties({ const wrap = generateMessageProperties({
buildContext, buildContext,
@ -364,27 +416,24 @@ export async function generateResources(params: {
); );
} }
i18n_single_page: { i18n_for_spas_and_native: {
if (!isSpa) { if (!isSpa && !isNative) {
break i18n_single_page; break i18n_for_spas_and_native;
} }
if (isLegacyAccountSpa) { if (isLegacyAccountSpa) {
break i18n_single_page; break i18n_for_spas_and_native;
} }
assert(themeType === "account" || themeType === "admin");
const messagesDirPath_theme = pathJoin( const messagesDirPath_theme = pathJoin(
buildContext.themeSrcDirPath, buildContext.themeSrcDirPath,
themeType, themeType,
"i18n" isNative ? "messages" : "i18n"
); );
assert( if (!fs.existsSync(messagesDirPath_theme)) {
fs.existsSync(messagesDirPath_theme), break i18n_for_spas_and_native;
`${messagesDirPath_theme} is supposed to exist` }
);
const propertiesByLang: Record< const propertiesByLang: Record<
string, string,
@ -524,6 +573,10 @@ export async function generateResources(params: {
} }
keycloak_static_resources: { keycloak_static_resources: {
if (isNative) {
break keycloak_static_resources;
}
if (isSpa) { if (isSpa) {
break keycloak_static_resources; break keycloak_static_resources;
} }
@ -540,77 +593,185 @@ export async function generateResources(params: {
}); });
} }
fs.writeFileSync( bring_in_account_v1: {
pathJoin(themeTypeDirPath, "theme.properties"), if (isNative) {
Buffer.from( break bring_in_account_v1;
[ }
`parent=${(() => {
switch (themeType) { if (themeType !== "account") {
case "account": break bring_in_account_v1;
switch (getAccountThemeType()) { }
case "Multi-Page":
return "account-v1"; assert(buildContext.implementedThemeTypes.account.isImplemented);
case "Single-Page":
return "base"; if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
} break bring_in_account_v1;
case "login": }
return "keycloak";
case "admin": transformCodebase({
return "base"; srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
} destDirPath: getThemeTypeDirPath({
assert<Equals<typeof themeType, never>>(false); themeName: "account-v1",
})()}`, themeType: "account"
...(themeType === "account" && getAccountThemeType() === "Single-Page" })
? ["deprecatedMode=false"] });
: []), }
...(buildContext.extraThemeProperties ?? []),
...[ generate_theme_properties: {
...buildContext.environmentVariables, if (isNative) {
{ name: KEYCLOAKIFY_SPA_DEV_SERVER_PORT, default: "" } break generate_theme_properties;
].map( }
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}` assert(themeType !== "email");
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
switch (getAccountThemeType()) {
case "Multi-Page":
return "account-v1";
case "Single-Page":
return "base";
}
case "login":
return "keycloak";
case "admin":
return "base";
}
assert<Equals<typeof themeType, never>>;
})()}`,
...(themeType === "account" &&
getAccountThemeType() === "Single-Page"
? ["deprecatedMode=false"]
: []),
...(buildContext.extraThemeProperties ?? []),
...[
...buildContext.environmentVariables,
{ name: KEYCLOAKIFY_SPA_DEV_SERVER_PORT, default: "" }
].map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
),
...(languageTags === undefined
? []
: [`locales=${languageTags.join(",")}`])
].join("\n\n"),
"utf8"
)
);
}
}
for (const themeVariantName of buildContext.themeNames) {
for (const themeType of [...THEME_TYPES, "email"] as const) {
copy_main_theme_to_theme_variant_theme: {
let isNative: boolean;
{
const v = buildContext.implementedThemeTypes[themeType];
if (!v.isImplemented && !v.isImplemented_native) {
break copy_main_theme_to_theme_variant_theme;
}
isNative = !v.isImplemented && v.isImplemented_native;
}
if (themeVariantName === themeName) {
break copy_main_theme_to_theme_variant_theme;
}
transformCodebase({
srcDirPath: pathJoin(resourcesDirPath, "theme", themeName),
destDirPath: pathJoin(resourcesDirPath, "theme", themeVariantName),
transformSourceCode: isNative
? undefined
: ({ fileRelativePath, sourceCode }) => {
if (
pathExtname(fileRelativePath) === ".ftl" &&
fileRelativePath.split(pathSep).length === 2
) {
const modifiedSourceCode = Buffer.from(
Buffer.from(sourceCode)
.toString("utf-8")
.replace(
`"themeName": "${themeName}"`,
`"themeName": "${themeVariantName}"`
),
"utf8"
);
assert(
Buffer.compare(modifiedSourceCode, sourceCode) !== 0
);
return { modifiedSourceCode };
}
return { modifiedSourceCode: sourceCode };
}
});
}
run_writeMessagePropertiesFiles: {
const writeMessagePropertiesFiles =
writeMessagePropertiesFilesByThemeType[themeType];
if (writeMessagePropertiesFiles === undefined) {
break run_writeMessagePropertiesFiles;
}
writeMessagePropertiesFiles({
messageDirPath: pathJoin(
getThemeTypeDirPath({ themeName: themeVariantName, themeType }),
"messages"
), ),
...(languageTags === undefined themeName: themeVariantName
? [] });
: [`locales=${languageTags.join(",")}`]) }
].join("\n\n"), replace_xKeycloakify_themeName_in_native_ftl_files: {
"utf8" {
) const v = buildContext.implementedThemeTypes[themeType];
);
} if (v.isImplemented || !v.isImplemented_native) {
break replace_xKeycloakify_themeName_in_native_ftl_files;
email: { }
if (!buildContext.implementedThemeTypes.email.isImplemented) { }
break email;
} const emailThemeDirPath = getThemeTypeDirPath({
themeName,
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email"); themeType
});
transformCodebase({
srcDirPath: emailThemeSrcDirPath, transformCodebase({
destDirPath: getThemeTypeDirPath({ themeName, themeType: "email" }) srcDirPath: emailThemeDirPath,
}); destDirPath: emailThemeDirPath,
} transformSourceCode: ({ filePath, sourceCode }) => {
if (!filePath.endsWith(".ftl")) {
bring_in_account_v1: { return { modifiedSourceCode: sourceCode };
if (!buildContext.implementedThemeTypes.account.isImplemented) { }
break bring_in_account_v1;
} return {
modifiedSourceCode: Buffer.from(
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") { sourceCode
break bring_in_account_v1; .toString("utf8")
} .replace(
/xKeycloakify\.themeName/g,
transformCodebase({ `"${themeName}"`
srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"), ),
destDirPath: getThemeTypeDirPath({ "utf8"
themeName: "account-v1", )
themeType: "account" };
}) }
}); });
}
}
} }
// Generate meta-inf/keycloak-themes.json
{ {
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] }; const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
@ -618,12 +779,15 @@ export async function generateResources(params: {
metaInfKeycloakThemes.themes.push({ metaInfKeycloakThemes.themes.push({
name: themeName, name: themeName,
types: objectEntries(buildContext.implementedThemeTypes) types: objectEntries(buildContext.implementedThemeTypes)
.filter(([, { isImplemented }]) => isImplemented) .filter(([, v]) => v.isImplemented || v.isImplemented_native)
.map(([themeType]) => themeType) .map(([themeType]) => themeType)
}); });
} }
if (buildContext.implementedThemeTypes.account.isImplemented) { if (
buildContext.implementedThemeTypes.account.isImplemented &&
buildContext.implementedThemeTypes.account.type === "Multi-Page"
) {
metaInfKeycloakThemes.themes.push({ metaInfKeycloakThemes.themes.push({
name: "account-v1", name: "account-v1",
types: ["account"] types: ["account"]
@ -635,88 +799,4 @@ export async function generateResources(params: {
getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes
}); });
} }
for (const themeVariantName of buildContext.themeNames) {
if (themeVariantName === themeName) {
continue;
}
transformCodebase({
srcDirPath: pathJoin(resourcesDirPath, "theme", themeName),
destDirPath: pathJoin(resourcesDirPath, "theme", themeVariantName),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (
pathExtname(fileRelativePath) === ".ftl" &&
fileRelativePath.split(pathSep).length === 2
) {
const modifiedSourceCode = Buffer.from(
Buffer.from(sourceCode)
.toString("utf-8")
.replace(
`"themeName": "${themeName}"`,
`"themeName": "${themeVariantName}"`
),
"utf8"
);
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
return { modifiedSourceCode };
}
return { modifiedSourceCode: sourceCode };
}
});
}
for (const themeName of buildContext.themeNames) {
for (const [themeType, writeMessagePropertiesFiles] of objectEntries(
writeMessagePropertiesFilesByThemeType
)) {
// NOTE: This is just a quirk of the type system: We can't really differentiate in a record
// between the case where the key isn't present and the case where the value is `undefined`.
if (writeMessagePropertiesFiles === undefined) {
return;
}
writeMessagePropertiesFiles({
messageDirPath: pathJoin(
getThemeTypeDirPath({ themeName, themeType }),
"messages"
),
themeName
});
}
}
modify_email_theme_per_variant: {
if (!buildContext.implementedThemeTypes.email.isImplemented) {
break modify_email_theme_per_variant;
}
for (const themeName of buildContext.themeNames) {
const emailThemeDirPath = getThemeTypeDirPath({
themeName,
themeType: "email"
});
transformCodebase({
srcDirPath: emailThemeDirPath,
destDirPath: emailThemeDirPath,
transformSourceCode: ({ filePath, sourceCode }) => {
if (!filePath.endsWith(".ftl")) {
return { modifiedSourceCode: sourceCode };
}
return {
modifiedSourceCode: Buffer.from(
sourceCode
.toString("utf8")
.replace(/xKeycloakify\.themeName/g, `"${themeName}"`),
"utf8"
)
};
}
});
}
}
} }

View File

@ -45,12 +45,16 @@ export type BuildContext = {
environmentVariables: { name: string; default: string }[]; environmentVariables: { name: string; default: string }[];
themeSrcDirPath: string; themeSrcDirPath: string;
implementedThemeTypes: { implementedThemeTypes: {
login: { isImplemented: boolean }; login:
email: { isImplemented: boolean }; | { isImplemented: true }
| { isImplemented: false; isImplemented_native: boolean };
email: { isImplemented: false; isImplemented_native: boolean };
account: account:
| { isImplemented: false } | { isImplemented: false; isImplemented_native: boolean }
| { isImplemented: true; type: "Single-Page" | "Multi-Page" }; | { isImplemented: true; type: "Single-Page" | "Multi-Page" };
admin: { isImplemented: boolean }; admin:
| { isImplemented: true }
| { isImplemented: false; isImplemented_native: boolean };
}; };
packageJsonFilePath: string; packageJsonFilePath: string;
bundler: "vite" | "webpack"; bundler: "vite" | "webpack";
@ -434,27 +438,68 @@ export function getBuildContext(params: {
assert<Equals<typeof bundler, never>>(false); assert<Equals<typeof bundler, never>>(false);
})(); })();
const implementedThemeTypes: BuildContext["implementedThemeTypes"] = { const implementedThemeTypes: BuildContext["implementedThemeTypes"] = (() => {
login: { const getIsNative = (dirPath: string) =>
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "login")) fs.existsSync(pathJoin(dirPath, "theme.properties"));
},
email: {
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "email"))
},
account: (() => {
if (buildOptions.accountThemeImplementation === "none") {
return { isImplemented: false };
}
return { return {
isImplemented: true, login: (() => {
type: buildOptions.accountThemeImplementation const dirPath = pathJoin(themeSrcDirPath, "login");
};
})(), if (!fs.existsSync(dirPath)) {
admin: { return { isImplemented: false, isImplemented_native: false };
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "admin")) }
}
}; if (getIsNative(dirPath)) {
return { isImplemented: false, isImplemented_native: true };
}
return { isImplemented: true };
})(),
email: (() => {
const dirPath = pathJoin(themeSrcDirPath, "email");
if (!fs.existsSync(dirPath) || !getIsNative(dirPath)) {
return { isImplemented: false, isImplemented_native: false };
}
return { isImplemented: false, isImplemented_native: true };
})(),
account: (() => {
const dirPath = pathJoin(themeSrcDirPath, "account");
if (!fs.existsSync(dirPath)) {
return { isImplemented: false, isImplemented_native: false };
}
if (getIsNative(dirPath)) {
return { isImplemented: false, isImplemented_native: true };
}
if (buildOptions.accountThemeImplementation === "none") {
return { isImplemented: false, isImplemented_native: false };
}
return {
isImplemented: true,
type: buildOptions.accountThemeImplementation
};
})(),
admin: (() => {
const dirPath = pathJoin(themeSrcDirPath, "admin");
if (!fs.existsSync(dirPath)) {
return { isImplemented: false, isImplemented_native: false };
}
if (getIsNative(dirPath)) {
return { isImplemented: false, isImplemented_native: true };
}
return { isImplemented: true };
})()
};
})();
if ( if (
implementedThemeTypes.account.isImplemented && implementedThemeTypes.account.isImplemented &&