keycloakify
Version:
Framework to create custom Keycloak UIs
368 lines (306 loc) • 12.5 kB
text/typescript
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import { dirname as pathDirname, join as pathJoin } from "path";
import type { BuildContext } from "./shared/buildContext";
import * as fs from "fs/promises";
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";
import cliSelect from "cli-select";
import { existsAsync } from "./tools/fs.existsAsync";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
commandName: "initialize-login-theme",
buildContext
});
if (hasBeenHandled) {
return;
}
if (
buildContext.implementedThemeTypes.login.isImplemented ||
buildContext.implementedThemeTypes.login.isImplemented_native
) {
console.warn(chalk.red("There is already a login theme in your project"));
process.exit(-1);
}
const parsedPackageJson = await (async () => {
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()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>;
return id<z.ZodType<TargetType>>(zTargetType);
})();
const parsedPackageJson = JSON.parse(
(await fs.readFile(buildContext.packageJsonFilePath)).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
})();
addSyncExtensionsToPostinstallScript({
parsedPackageJson,
buildContext
});
const doInstallStories = await (async () => {
console.log(chalk.cyan(`\nDo you want to install the Stories?`));
const YES = "Yes (Recommended)";
const NO = "No";
const { value } = await cliSelect({
values: [YES, NO]
}).catch(() => {
process.exit(-1);
});
console.log(`${value}\n`);
return value === YES;
})();
install_storybook: {
if (!doInstallStories) {
break install_storybook;
}
if (buildContext.bundler !== "vite") {
break install_storybook;
}
if (
Object.keys({
...parsedPackageJson.dependencies,
...parsedPackageJson.devDependencies
}).includes("storybook")
) {
break install_storybook;
}
(parsedPackageJson.scripts ??= {})["storybook"] = "storybook dev -p 6006";
parsedPackageJson.scripts["build-storybook"] = "storybook build";
(parsedPackageJson.devDependencies ??= {})["storybook"] = "^9.0.4";
parsedPackageJson.devDependencies["@storybook/react-vite"] = "^9.0.4";
const files: { relativeFilePath: string; fileContent: string }[] = [
{
relativeFilePath: "main.ts",
fileContent: [
`import type { StorybookConfig } from "@storybook/react-vite";`,
``,
`const config: StorybookConfig = {`,
` stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],`,
` addons: [],`,
` framework: {`,
` name: "@storybook/react-vite",`,
` options: {}`,
` },`,
`};`,
`export default config;`,
``
].join("\n")
},
{
relativeFilePath: "preview.ts",
fileContent: storybookPreviewTsFileContent
}
];
for (let { relativeFilePath, fileContent } of files) {
const filePath = pathJoin(
buildContext.projectDirPath,
".storybook",
relativeFilePath
);
{
const dirPath = pathDirname(filePath);
if (!(await existsAsync(dirPath))) {
await fs.mkdir(dirPath, { recursive: true });
}
}
run_prettier: {
if (!(await getIsPrettierAvailable())) {
break run_prettier;
}
fileContent = await runPrettier({
filePath: filePath,
sourceCode: fileContent
});
}
await fs.writeFile(filePath, Buffer.from(fileContent, "utf8"));
}
}
{
const moduleName = "@keycloakify/login-ui";
const latestVersion = getModuleLatestVersion({ moduleName });
(parsedPackageJson.dependencies ??= {})[moduleName] = `~${latestVersion}`;
if (parsedPackageJson.devDependencies !== undefined) {
delete parsedPackageJson.devDependencies[moduleName];
}
}
install_stories: {
if (!doInstallStories) {
break install_stories;
}
const moduleName = "@keycloakify/login-ui-storybook";
const latestVersion = getModuleLatestVersion({ moduleName });
(parsedPackageJson.devDependencies ??= {})[moduleName] = `~${latestVersion}`;
delete parsedPackageJson.dependencies[moduleName];
}
for (const fileBasename of ["main-kc.dev.tsx", "main.dev.tsx"]) {
const filePath = pathJoin(buildContext.themeSrcDirPath, fileBasename);
if (!(await existsAsync(filePath))) {
continue;
}
const content = (await fs.readFile(filePath)).toString("utf8");
if (!content.includes("export {}")) {
break;
}
let content_new = [
`import { createRoot } from "react-dom/client";`,
`import { StrictMode } from "react";`,
`import { KcPage } from "./kc.gen";`,
`import { getKcContextMock } from "./login/mocks/getKcContextMock";`,
``,
`const kcContext = getKcContextMock({`,
` pageId: "login.ftl",`,
` overrides: {}`,
`});`,
``,
`createRoot(document.getElementById("root")!).render(`,
` <StrictMode>`,
` <KcPage kcContext={kcContext} />`,
` </StrictMode>`,
`);`,
``
].join("\n");
if (await getIsPrettierAvailable()) {
content_new = await runPrettier({
sourceCode: content_new,
filePath
});
}
await fs.writeFile(filePath, content_new);
break;
}
{
let sourceCode = JSON.stringify(parsedPackageJson, null, 2);
if (await getIsPrettierAvailable()) {
sourceCode = await runPrettier({
sourceCode,
filePath: buildContext.packageJsonFilePath
});
}
await fs.writeFile(
buildContext.packageJsonFilePath,
Buffer.from(sourceCode, "utf8")
);
}
await npmInstall({
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
});
}
function getModuleLatestVersion(params: { moduleName: string }) {
const { moduleName } = params;
const versions = ((): string[] => {
const cmdOutput = child_process
.execSync(`npm show ${moduleName} versions --json`)
.toString("utf8")
.trim();
const versions = JSON.parse(cmdOutput) as string | string[];
// NOTE: Bug in some older npm versions
if (typeof versions === "string") {
return [versions];
}
return versions;
})();
const version = versions.reverse().filter(version => !version.includes("-"))[0];
assert(version !== undefined);
return version;
}
const storybookPreviewTsFileContent = [
`import type { Preview } from "@storybook/react-vite";`,
``,
`const preview: Preview = {`,
` parameters: {`,
` controls: {`,
` matchers: {`,
` color: /(background|color)$/i,`,
` date: /Date$/i`,
` }`,
` },`,
` options: {`,
` storySort: (a, b)=> {`,
``,
` const orderedPagesPrefix = [`,
` "Introduction",`,
` "login/login.ftl",`,
` "login/register.ftl",`,
` "login/terms.ftl",`,
` "login/error.ftl",`,
` "login/code.ftl",`,
` "login/delete-account-confirm.ftl",`,
` "login/delete-credential.ftl",`,
` "login/frontchannel-logout.ftl",`,
` "login/idp-review-user-profile.ftl",`,
` "login/info.ftl",`,
` "login/login-config-totp.ftl",`,
` "login/login-idp-link-confirm.ftl",`,
` "login/login-idp-link-email.ftl",`,
` "login/login-oauth-grant.ftl",`,
` "login/login-otp.ftl",`,
` "login/login-page-expired.ftl",`,
` "login/login-password.ftl",`,
` "login/login-reset-otp.ftl",`,
` "login/login-reset-password.ftl",`,
` "login/login-update-password.ftl",`,
` "login/login-update-profile.ftl",`,
` "login/login-username.ftl",`,
` "login/login-verify-email.ftl",`,
` "login/login-x509-info.ftl",`,
` "login/logout-confirm.ftl",`,
` "login/saml-post-form.ftl",`,
` "login/select-authenticator.ftl",`,
` "login/update-email.ftl",`,
` "login/webauthn-authenticate.ftl",`,
` "login/webauthn-error.ftl",`,
` "login/webauthn-register.ftl",`,
` "login/login-oauth2-device-verify-user-code.ftl",`,
` "login/login-recovery-authn-code-config.ftl",`,
` "login/login-recovery-authn-code-input.ftl",`,
` "account/account.ftl",`,
` "account/password.ftl",`,
` "account/federatedIdentity.ftl",`,
` "account/log.ftl",`,
` "account/sessions.ftl",`,
` "account/totp.ftl"`,
` ];`,
``,
` function getHardCodedWeight(title) {`,
` for (let i = 0; i < orderedPagesPrefix.length; i++) {`,
` if (`,
` title`,
` .toLowerCase()`,
` .startsWith(orderedPagesPrefix[i].toLowerCase())`,
` ) {`,
` return orderedPagesPrefix.length - i;`,
` }`,
` }`,
``,
` return 0;`,
` }`,
``,
` return getHardCodedWeight(b.title) - getHardCodedWeight(a.title);`,
``,
` }`,
``,
` }`,
` }`,
`};`,
``,
`export default preview;`,
``
].join("\n");