Compare commits

..

14 Commits

16 changed files with 635 additions and 461 deletions

View File

@ -1,6 +1,6 @@
{
"name": "keycloakify",
"version": "11.7.2",
"version": "11.8.3",
"description": "Framework to create custom Keycloak UIs",
"repository": {
"type": "git",

View File

@ -92,12 +92,14 @@ export async function command(params: { buildContext: BuildContext }) {
const templateValue = "Template.tsx (Layout common to every page)";
const userProfileFormFieldsValue =
"UserProfileFormFields.tsx (Renders the form of the register.ftl, login-update-profile.ftl, update-email.ftl and idp-review-user-profile.ftl)";
const otherPageValue = "The page you're looking for isn't listed here";
const { value: pageIdOrComponent } = await cliSelect<
| LoginThemePageId
| AccountThemePageId
| typeof templateValue
| typeof userProfileFormFieldsValue
| typeof otherPageValue
>({
values: (() => {
switch (themeType) {
@ -105,10 +107,11 @@ export async function command(params: { buildContext: BuildContext }) {
return [
templateValue,
userProfileFormFieldsValue,
...LOGIN_THEME_PAGE_IDS
...LOGIN_THEME_PAGE_IDS,
otherPageValue
];
case "account":
return [templateValue, ...ACCOUNT_THEME_PAGE_IDS];
return [templateValue, ...ACCOUNT_THEME_PAGE_IDS, otherPageValue];
}
assert<Equals<typeof themeType, never>>(false);
})()
@ -116,6 +119,17 @@ export async function command(params: { buildContext: BuildContext }) {
process.exit(-1);
});
if (pageIdOrComponent === otherPageValue) {
console.log(
[
"To style a page not included in the base Keycloak, such as one added by a third-party Keycloak extension,",
"refer to the documentation: https://docs.keycloakify.dev/features/styling-a-custom-page-not-included-in-base-keycloak"
].join(" ")
);
process.exit(0);
}
console.log(`${pageIdOrComponent}`);
const componentBasename = (() => {

View File

@ -23,22 +23,6 @@ export async function command(params: { buildContext: BuildContext }) {
const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
if (
fs.existsSync(accountThemeSrcDirPath) &&
fs.readdirSync(accountThemeSrcDirPath).length > 0
) {
console.warn(
chalk.red(
`There is already a ${pathRelative(
process.cwd(),
accountThemeSrcDirPath
)} directory in your project. Aborting.`
)
);
process.exit(-1);
}
exitIfUncommittedChanges({
projectDirPath: buildContext.projectDirPath
});
@ -51,17 +35,35 @@ export async function command(params: { buildContext: BuildContext }) {
switch (accountThemeType) {
case "Multi-Page":
fs.cpSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"initialize-account-theme",
"multi-page-boilerplate"
),
accountThemeSrcDirPath,
{ recursive: true }
);
{
if (
fs.existsSync(accountThemeSrcDirPath) &&
fs.readdirSync(accountThemeSrcDirPath).length > 0
) {
console.warn(
chalk.red(
`There is already a ${pathRelative(
process.cwd(),
accountThemeSrcDirPath
)} directory in your project. Aborting.`
)
);
process.exit(-1);
}
fs.cpSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"initialize-account-theme",
"multi-page-boilerplate"
),
accountThemeSrcDirPath,
{ recursive: true }
);
}
break;
case "Single-Page":
{

View File

@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { i18nBuilder } from "keycloakify/account";
import type { ThemeName } from "../kc.gen";
/** @see: https://docs.keycloakify.dev/i18n */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
/** @see: https://docs.keycloakify.dev/features/i18n */
const { useI18n, ofTypeI18n } = i18nBuilder.withThemeName<ThemeName>().build();
type I18n = typeof ofTypeI18n;

View File

@ -1,17 +1,24 @@
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import type { BuildContext } from "./shared/buildContext";
import * as fs from "fs";
import { downloadAndExtractArchive } from "./tools/downloadAndExtractArchive";
import cliSelect from "cli-select";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import { assert } from "tsafe/assert";
import { getSupportedDockerImageTags } from "./start-keycloak/getSupportedDockerImageTags";
import { exitIfUncommittedChanges } from "./shared/exitIfUncommittedChanges";
import { dirname as pathDirname, join as pathJoin, relative as pathRelative } from "path";
import * as fs from "fs";
import { assert, is, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
import { addSyncExtensionsToPostinstallScript } from "./shared/addSyncExtensionsToPostinstallScript";
import { getIsPrettierAvailable, runPrettier } from "./tools/runPrettier";
import { npmInstall } from "./tools/npmInstall";
import * as child_process from "child_process";
import { z } from "zod";
import chalk from "chalk";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "initialize-email-theme",
commandName: "initialize-account-theme",
buildContext
});
@ -19,6 +26,10 @@ export async function command(params: { buildContext: BuildContext }) {
return;
}
exitIfUncommittedChanges({
projectDirPath: buildContext.projectDirPath
});
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
if (
@ -26,71 +37,110 @@ export async function command(params: { buildContext: BuildContext }) {
fs.readdirSync(emailThemeSrcDirPath).length > 0
) {
console.warn(
`There is already a non empty ${pathRelative(
process.cwd(),
emailThemeSrcDirPath
)} directory in your project. Aborting.`
chalk.red(
`There is already a ${pathRelative(
process.cwd(),
emailThemeSrcDirPath
)} directory in your project. Aborting.`
)
);
process.exit(-1);
}
console.log("Initialize with the base email theme from which version of Keycloak?");
const { value: emailThemeType } = await cliSelect({
values: ["native (FreeMarker)" as const, "jsx-email (React)" as const]
}).catch(() => {
process.exit(-1);
});
const { extractedDirPath } = await downloadAndExtractArchive({
url: await (async () => {
const { latestMajorTags } = await getSupportedDockerImageTags({
buildContext
if (emailThemeType === "jsx-email (React)") {
console.log(
[
"There is currently no automated support for keycloakify-email, it has to be done manually, see documentation:",
"https://docs.keycloakify.dev/theme-types/email-theme"
].join("\n")
);
process.exit(0);
}
const parsedPackageJson = (() => {
type ParsedPackageJson = {
scripts?: Record<string, string | undefined>;
dependencies?: Record<string, string | undefined>;
devDependencies?: Record<string, string | undefined>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
scripts: z.record(z.union([z.string(), z.undefined()])).optional(),
dependencies: z.record(z.union([z.string(), z.undefined()])).optional(),
devDependencies: z.record(z.union([z.string(), z.undefined()])).optional()
});
const keycloakVersion = latestMajorTags[0];
assert<Equals<z.infer<typeof zTargetType>, TargetType>>;
assert(keycloakVersion !== undefined);
return id<z.ZodType<TargetType>>(zTargetType);
})();
const parsedPackageJson = JSON.parse(
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
);
return `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`;
})(),
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "extractOnlyEmailTheme",
onArchiveFile: async ({ fileRelativePath, writeFile }) => {
const fileRelativePath_target = pathRelative(
pathJoin("theme", "base", "email"),
fileRelativePath
);
zParsedPackageJson.parse(parsedPackageJson);
if (fileRelativePath_target.startsWith("..")) {
return;
}
assert(is<ParsedPackageJson>(parsedPackageJson));
await writeFile({ fileRelativePath: fileRelativePath_target });
}
return parsedPackageJson;
})();
addSyncExtensionsToPostinstallScript({
parsedPackageJson,
buildContext
});
transformCodebase({
srcDirPath: extractedDirPath,
destDirPath: emailThemeSrcDirPath
});
const moduleName = `@keycloakify/email-native`;
const [version] = (
JSON.parse(
child_process
.execSync(`npm show ${moduleName} versions --json`)
.toString("utf8")
.trim()
) as string[]
)
.reverse()
.filter(version => !version.includes("-"));
assert(version !== undefined);
(parsedPackageJson.dependencies ??= {})[moduleName] = `~${version}`;
if (parsedPackageJson.devDependencies !== undefined) {
delete parsedPackageJson.devDependencies[moduleName];
}
{
const themePropertyFilePath = pathJoin(emailThemeSrcDirPath, "theme.properties");
let sourceCode = JSON.stringify(parsedPackageJson, undefined, 2);
if (await getIsPrettierAvailable()) {
sourceCode = await runPrettier({
sourceCode,
filePath: buildContext.packageJsonFilePath
});
}
fs.writeFileSync(
themePropertyFilePath,
Buffer.from(
[
`parent=base`,
fs.readFileSync(themePropertyFilePath).toString("utf8")
].join("\n"),
"utf8"
)
buildContext.packageJsonFilePath,
Buffer.from(sourceCode, "utf8")
);
}
console.log(
`The \`${pathJoin(
".",
pathRelative(process.cwd(), emailThemeSrcDirPath)
)}\` directory have been created.`
);
console.log("You can delete any file you don't modify.");
await npmInstall({
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
});
console.log(chalk.green("Email theme initialized."));
}

View File

@ -15,7 +15,6 @@ import { readFileSync } from "fs";
import { isInside } from "../../tools/isInside";
import child_process from "child_process";
import { rmSync } from "../../tools/fs.rmSync";
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
import { existsAsync } from "../../tools/fs.existsAsync";
export type BuildContextLike = BuildContextLike_generatePom & {
@ -106,29 +105,55 @@ export async function buildJar(params: {
}
});
remove_account_v1_in_meta_inf: {
if (!doesImplementAccountV1Theme) {
// NOTE: We do not have account v1 anyway
break remove_account_v1_in_meta_inf;
}
{
const filePath = pathJoin(
tmpResourcesDirPath,
"META-INF",
"keycloak-themes.json"
);
if (keycloakAccountV1Version !== null) {
// NOTE: No, we need to keep account-v1 in meta-inf
break remove_account_v1_in_meta_inf;
}
await fs.mkdir(pathDirname(filePath));
writeMetaInfKeycloakThemes({
resourcesDirPath: tmpResourcesDirPath,
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => {
assert(metaInfKeycloakTheme !== undefined);
await fs.writeFile(
filePath,
Buffer.from(
JSON.stringify(
{
themes: await (async () => {
const dirPath = pathJoin(tmpResourcesDirPath, "theme");
metaInfKeycloakTheme.themes = metaInfKeycloakTheme.themes.filter(
({ name }) => name !== "account-v1"
);
const themeNames = (await fs.readdir(dirPath)).sort(
(a, b) => {
const indexA = buildContext.themeNames.indexOf(a);
const indexB = buildContext.themeNames.indexOf(b);
return metaInfKeycloakTheme;
}
});
const orderA = indexA === -1 ? Infinity : indexA;
const orderB = indexB === -1 ? Infinity : indexB;
return orderA - orderB;
}
);
return Promise.all(
themeNames.map(async themeName => {
const types = await fs.readdir(
pathJoin(dirPath, themeName)
);
return {
name: themeName,
types
};
})
);
})()
},
null,
2
),
"utf8"
)
);
}
route_legacy_pages: {

View File

@ -31,16 +31,12 @@ import {
type BuildContextLike as BuildContextLike_generateMessageProperties
} from "./generateMessageProperties";
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
import {
writeMetaInfKeycloakThemes,
type MetaInfKeycloakTheme
} from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
import propertiesParser from "properties-parser";
import { createObjectThatThrowsIfAccessed } from "../../tools/createObjectThatThrowsIfAccessed";
import { listInstalledModules } from "../../tools/listInstalledModules";
import { isInside } from "../../tools/isInside";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_generateMessageProperties & {
@ -61,6 +57,8 @@ export async function generateResources(params: {
buildContext: BuildContextLike;
resourcesDirPath: string;
}): Promise<void> {
const start = Date.now();
const { resourcesDirPath, buildContext } = params;
const [themeName] = buildContext.themeNames;
@ -78,12 +76,23 @@ export async function generateResources(params: {
};
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) {
if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
continue;
for (const themeType of [...THEME_TYPES, "email"] as const) {
let isNative: boolean;
{
const v = buildContext.implementedThemeTypes[themeType];
if (!v.isImplemented && !v.isImplemented_native) {
continue;
}
isNative = !v.isImplemented && v.isImplemented_native;
}
const getAccountThemeType = () => {
@ -102,12 +111,18 @@ export async function generateResources(params: {
return getAccountThemeType() === "Single-Page";
case "admin":
return true;
case "email":
return false;
}
})();
const themeTypeDirPath = getThemeTypeDirPath({ themeName, themeType });
apply_replacers_and_move_to_theme_resources: {
if (isNative) {
break apply_replacers_and_move_to_theme_resources;
}
const destDirPath = pathJoin(
themeTypeDirPath,
"resources",
@ -191,59 +206,93 @@ export async function generateResources(params: {
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
indexHtmlCode: fs
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
.toString("utf8"),
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: isSpa
? []
: (assert(themeType !== "admin"),
readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}))
});
generate_ftl_files: {
if (isNative) {
break generate_ftl_files;
}
[
...(() => {
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"];
assert(themeType !== "email");
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
indexHtmlCode: fs
.readFileSync(
pathJoin(buildContext.projectBuildDirPath, "index.html")
)
.toString("utf8"),
buildContext,
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;
i18n_multi_page: {
if (isNative) {
break i18n_multi_page;
}
if (isSpa) {
break i18n_multi_page;
}
assert(themeType !== "admin");
assert(themeType !== "admin" && themeType !== "email");
const wrap = generateMessageProperties({
buildContext,
@ -364,27 +413,24 @@ export async function generateResources(params: {
);
}
i18n_single_page: {
if (!isSpa) {
break i18n_single_page;
i18n_for_spas_and_native: {
if (!isSpa && !isNative) {
break i18n_for_spas_and_native;
}
if (isLegacyAccountSpa) {
break i18n_single_page;
break i18n_for_spas_and_native;
}
assert(themeType === "account" || themeType === "admin");
const messagesDirPath_theme = pathJoin(
buildContext.themeSrcDirPath,
themeType,
"i18n"
isNative ? "messages" : "i18n"
);
assert(
fs.existsSync(messagesDirPath_theme),
`${messagesDirPath_theme} is supposed to exist`
);
if (!fs.existsSync(messagesDirPath_theme)) {
break i18n_for_spas_and_native;
}
const propertiesByLang: Record<
string,
@ -524,6 +570,10 @@ export async function generateResources(params: {
}
keycloak_static_resources: {
if (isNative) {
break keycloak_static_resources;
}
if (isSpa) {
break keycloak_static_resources;
}
@ -540,183 +590,188 @@ export async function generateResources(params: {
});
}
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>>(false);
})()}`,
...(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"
)
);
}
bring_in_account_v1: {
if (isNative) {
break bring_in_account_v1;
}
email: {
if (!buildContext.implementedThemeTypes.email.isImplemented) {
break email;
}
if (themeType !== "account") {
break bring_in_account_v1;
}
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
assert(buildContext.implementedThemeTypes.account.isImplemented);
transformCodebase({
srcDirPath: emailThemeSrcDirPath,
destDirPath: getThemeTypeDirPath({ themeName, themeType: "email" })
});
}
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
break bring_in_account_v1;
}
bring_in_account_v1: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v1;
}
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
break bring_in_account_v1;
}
transformCodebase({
srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
destDirPath: getThemeTypeDirPath({
themeName: "account-v1",
themeType: "account"
})
});
}
{
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
for (const themeName of buildContext.themeNames) {
metaInfKeycloakThemes.themes.push({
name: themeName,
types: objectEntries(buildContext.implementedThemeTypes)
.filter(([, { isImplemented }]) => isImplemented)
.map(([themeType]) => themeType)
transformCodebase({
srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
destDirPath: getThemeTypeDirPath({
themeName: "account-v1",
themeType: "account"
})
});
}
if (buildContext.implementedThemeTypes.account.isImplemented) {
metaInfKeycloakThemes.themes.push({
name: "account-v1",
types: ["account"]
});
}
generate_theme_properties: {
if (isNative) {
break generate_theme_properties;
}
writeMetaInfKeycloakThemes({
resourcesDirPath,
getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes
});
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) {
if (themeVariantName === themeName) {
continue;
}
for (const themeType of [...THEME_TYPES, "email"] as const) {
copy_main_theme_to_theme_variant_theme: {
let isNative: boolean;
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"
);
{
const v = buildContext.implementedThemeTypes[themeType];
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 };
if (!v.isImplemented && !v.isImplemented_native) {
break copy_main_theme_to_theme_variant_theme;
}
return {
modifiedSourceCode: Buffer.from(
sourceCode
.toString("utf8")
.replace(/xKeycloakify\.themeName/g, `"${themeName}"`),
"utf8"
)
};
isNative = !v.isImplemented && v.isImplemented_native;
}
});
if (themeVariantName === themeName) {
break copy_main_theme_to_theme_variant_theme;
}
transformCodebase({
srcDirPath: pathJoin(resourcesDirPath, "theme", themeName, themeType),
destDirPath: pathJoin(
resourcesDirPath,
"theme",
themeVariantName,
themeType
),
transformSourceCode: isNative
? undefined
: ({ fileRelativePath, sourceCode }) => {
if (
pathExtname(fileRelativePath) === ".ftl" &&
fileRelativePath.split(pathSep).length === 1
) {
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"
),
themeName: themeVariantName
});
}
replace_xKeycloakify_themeName_in_native_ftl_files: {
{
const v = buildContext.implementedThemeTypes[themeType];
if (v.isImplemented || !v.isImplemented_native) {
break replace_xKeycloakify_themeName_in_native_ftl_files;
}
}
const emailThemeDirPath = getThemeTypeDirPath({
themeName: themeVariantName,
themeType
});
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,
`"${themeVariantName}"`
),
"utf8"
)
};
}
});
}
}
}
console.log(`Generated resources in ${Date.now() - start}ms`);
}

View File

@ -1,6 +1,6 @@
import { dirname as pathDirname, relative as pathRelative, sep as pathSep } from "path";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../buildContext";
import type { BuildContext } from "./buildContext";
export type BuildContextLike = {
projectDirPath: string;

View File

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

View File

@ -1,5 +1,5 @@
import { dirname as pathDirname, join as pathJoin, relative as pathRelative } from "path";
import type { BuildContext } from "../buildContext";
import type { BuildContext } from "./buildContext";
import * as fs from "fs";
import { assert, is, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
@ -7,8 +7,8 @@ import {
addSyncExtensionsToPostinstallScript,
type BuildContextLike as BuildContextLike_addSyncExtensionsToPostinstallScript
} from "./addSyncExtensionsToPostinstallScript";
import { getIsPrettierAvailable, runPrettier } from "../../tools/runPrettier";
import { npmInstall } from "../../tools/npmInstall";
import { getIsPrettierAvailable, runPrettier } from "../tools/runPrettier";
import { npmInstall } from "../tools/npmInstall";
import * as child_process from "child_process";
import { z } from "zod";
import chalk from "chalk";

View File

@ -1 +0,0 @@
export * from "./initializeSpa";

View File

@ -1,40 +0,0 @@
import { join as pathJoin, dirname as pathDirname } from "path";
import type { ThemeType } from "./constants";
import * as fs from "fs";
export type MetaInfKeycloakTheme = {
themes: { name: string; types: (ThemeType | "email")[] }[];
};
export function writeMetaInfKeycloakThemes(params: {
resourcesDirPath: string;
getNewMetaInfKeycloakTheme: (params: {
metaInfKeycloakTheme: MetaInfKeycloakTheme | undefined;
}) => MetaInfKeycloakTheme;
}) {
const { resourcesDirPath, getNewMetaInfKeycloakTheme } = params;
const filePath = pathJoin(resourcesDirPath, "META-INF", "keycloak-themes.json");
const currentMetaInfKeycloakTheme = !fs.existsSync(filePath)
? undefined
: (JSON.parse(
fs.readFileSync(filePath).toString("utf8")
) as MetaInfKeycloakTheme);
const newMetaInfKeycloakThemes = getNewMetaInfKeycloakTheme({
metaInfKeycloakTheme: currentMetaInfKeycloakTheme
});
{
const dirPath = pathDirname(filePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
fs.writeFileSync(
filePath,
Buffer.from(JSON.stringify(newMetaInfKeycloakThemes, null, 2), "utf8")
);
}

View File

@ -1,28 +1,20 @@
import { assert } from "tsafe/assert";
import type { ParsedRealmJson } from "./ParsedRealmJson";
import { getDefaultConfig } from "./defaultConfig";
import type { BuildContext } from "../../shared/buildContext";
import { objectKeys } from "tsafe/objectKeys";
import { TEST_APP_URL } from "../../shared/constants";
import { TEST_APP_URL, type ThemeType, THEME_TYPES } from "../../shared/constants";
import { sameFactory } from "evt/tools/inDepth/same";
export type BuildContextLike = {
themeNames: BuildContext["themeNames"];
implementedThemeTypes: BuildContext["implementedThemeTypes"];
};
assert<BuildContext extends BuildContextLike ? true : false>;
export function prepareRealmConfig(params: {
parsedRealmJson: ParsedRealmJson;
keycloakMajorVersionNumber: number;
buildContext: BuildContextLike;
parsedKeycloakThemesJsonEntry: { name: string; types: (ThemeType | "email")[] };
}): {
realmName: string;
clientName: string;
username: string;
} {
const { parsedRealmJson, keycloakMajorVersionNumber, buildContext } = params;
const { parsedRealmJson, keycloakMajorVersionNumber, parsedKeycloakThemesJsonEntry } =
params;
const { username } = addOrEditTestUser({
parsedRealmJson,
@ -38,8 +30,7 @@ export function prepareRealmConfig(params: {
enableCustomThemes({
parsedRealmJson,
themeName: buildContext.themeNames[0],
implementedThemeTypes: buildContext.implementedThemeTypes
parsedKeycloakThemesJsonEntry
});
enable_custom_events_listeners: {
@ -63,17 +54,15 @@ export function prepareRealmConfig(params: {
function enableCustomThemes(params: {
parsedRealmJson: ParsedRealmJson;
themeName: string;
implementedThemeTypes: BuildContextLike["implementedThemeTypes"];
parsedKeycloakThemesJsonEntry: { name: string; types: (ThemeType | "email")[] };
}) {
const { parsedRealmJson, themeName, implementedThemeTypes } = params;
const { parsedRealmJson, parsedKeycloakThemesJsonEntry } = params;
for (const themeType of objectKeys(implementedThemeTypes)) {
if (!implementedThemeTypes[themeType].isImplemented) {
continue;
}
parsedRealmJson[`${themeType}Theme` as const] = themeName;
for (const themeType of [...THEME_TYPES, "email"] as const) {
parsedRealmJson[`${themeType}Theme` as const] =
!parsedKeycloakThemesJsonEntry.types.includes(themeType)
? ""
: parsedKeycloakThemesJsonEntry.name;
}
}

View File

@ -1,10 +1,7 @@
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import { getDefaultConfig } from "./defaultConfig";
import {
prepareRealmConfig,
type BuildContextLike as BuildContextLike_prepareRealmConfig
} from "./prepareRealmConfig";
import { prepareRealmConfig } from "./prepareRealmConfig";
import * as fs from "fs";
import {
join as pathJoin,
@ -24,18 +21,19 @@ import {
} from "./dumpContainerConfig";
import * as runExclusive from "run-exclusive";
import { waitForDebounceFactory } from "powerhooks/tools/waitForDebounce";
import type { ThemeType } from "../../shared/constants";
import chalk from "chalk";
export type BuildContextLike = BuildContextLike_dumpContainerConfig &
BuildContextLike_prepareRealmConfig & {
projectDirPath: string;
};
export type BuildContextLike = BuildContextLike_dumpContainerConfig & {
projectDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>;
export async function getRealmConfig(params: {
keycloakMajorVersionNumber: number;
realmJsonFilePath_userProvided: string | undefined;
parsedKeycloakThemesJsonEntry: { name: string; types: (ThemeType | "email")[] };
buildContext: BuildContextLike;
}): Promise<{
realmJsonFilePath: string;
@ -44,8 +42,12 @@ export async function getRealmConfig(params: {
username: string;
onRealmConfigChange: () => Promise<void>;
}> {
const { keycloakMajorVersionNumber, realmJsonFilePath_userProvided, buildContext } =
params;
const {
keycloakMajorVersionNumber,
realmJsonFilePath_userProvided,
parsedKeycloakThemesJsonEntry,
buildContext
} = params;
const realmJsonFilePath = pathJoin(
buildContext.projectDirPath,
@ -71,8 +73,8 @@ export async function getRealmConfig(params: {
const { clientName, realmName, username } = prepareRealmConfig({
parsedRealmJson,
buildContext,
keycloakMajorVersionNumber
keycloakMajorVersionNumber,
parsedKeycloakThemesJsonEntry
});
{

View File

@ -4,7 +4,8 @@ import {
CONTAINER_NAME,
KEYCLOAKIFY_SPA_DEV_SERVER_PORT,
KEYCLOAKIFY_LOGIN_JAR_BASENAME,
TEST_APP_URL
TEST_APP_URL,
ThemeType
} from "../shared/constants";
import { SemVer } from "../tools/SemVer";
import { assert, type Equals } from "tsafe/assert";
@ -34,6 +35,7 @@ import { startViteDevServer } from "./startViteDevServer";
import { getSupportedKeycloakMajorVersions } from "./realmConfig/defaultConfig";
import { getSupportedDockerImageTags } from "./getSupportedDockerImageTags";
import { getRealmConfig } from "./realmConfig";
import { id } from "tsafe/id";
export async function command(params: {
buildContext: BuildContext;
@ -270,32 +272,6 @@ export async function command(params: {
return wrap.majorVersionNumber;
})();
const { clientName, onRealmConfigChange, realmJsonFilePath, realmName, username } =
await getRealmConfig({
keycloakMajorVersionNumber,
realmJsonFilePath_userProvided: await (async () => {
if (cliCommandOptions.realmJsonFilePath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.realmJsonFilePath,
cwd: process.cwd()
});
}
if (buildContext.startKeycloakOptions.realmJsonFilePath !== undefined) {
assert(
await existsAsync(
buildContext.startKeycloakOptions.realmJsonFilePath
),
`${pathRelative(process.cwd(), buildContext.startKeycloakOptions.realmJsonFilePath)} does not exist`
);
return buildContext.startKeycloakOptions.realmJsonFilePath;
}
return undefined;
})(),
buildContext
});
{
const { isAppBuildSuccess } = await appBuild({
buildContext
@ -376,10 +352,24 @@ export async function command(params: {
))
];
let parsedKeycloakThemesJson = id<
{ themes: { name: string; types: (ThemeType | "email")[] }[] } | undefined
>(undefined);
async function extractThemeResourcesFromJar() {
await extractArchive({
archiveFilePath: jarFilePath,
onArchiveFile: async ({ relativeFilePathInArchive, writeFile }) => {
onArchiveFile: async ({ relativeFilePathInArchive, writeFile, readFile }) => {
if (
relativeFilePathInArchive ===
pathJoin("META-INF", "keycloak-themes.json") &&
parsedKeycloakThemesJson === undefined
) {
parsedKeycloakThemesJson = JSON.parse(
(await readFile()).toString("utf8")
);
}
if (isInside({ dirPath: "theme", filePath: relativeFilePathInArchive })) {
await writeFile({
filePath: pathJoin(
@ -401,6 +391,43 @@ export async function command(params: {
await extractThemeResourcesFromJar();
assert(parsedKeycloakThemesJson !== undefined);
const { clientName, onRealmConfigChange, realmJsonFilePath, realmName, username } =
await getRealmConfig({
keycloakMajorVersionNumber,
parsedKeycloakThemesJsonEntry: (() => {
const entry = parsedKeycloakThemesJson.themes.find(
({ name }) => name === buildContext.themeNames[0]
);
assert(entry !== undefined);
return entry;
})(),
realmJsonFilePath_userProvided: await (async () => {
if (cliCommandOptions.realmJsonFilePath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.realmJsonFilePath,
cwd: process.cwd()
});
}
if (buildContext.startKeycloakOptions.realmJsonFilePath !== undefined) {
assert(
await existsAsync(
buildContext.startKeycloakOptions.realmJsonFilePath
),
`${pathRelative(process.cwd(), buildContext.startKeycloakOptions.realmJsonFilePath)} does not exist`
);
return buildContext.startKeycloakOptions.realmJsonFilePath;
}
return undefined;
})(),
buildContext
});
const jarFilePath_cacheDir = pathJoin(
buildContext.cacheDirPath,
pathBasename(jarFilePath)

View File

@ -99,6 +99,12 @@ function addCommentToSourceCode(params: {
return toResult(commentLines.map(line => `# ${line}`).join("\n"));
}
if (fileRelativePath.endsWith(".ftl")) {
return toResult(
[`<#--`, ...commentLines.map(line => ` ${line}`), `-->`].join("\n")
);
}
if (fileRelativePath.endsWith(".html") || fileRelativePath.endsWith(".svg")) {
const comment = [
`<!--`,