diff --git a/package.json b/package.json index 82372b27..b8a4205f 100755 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "react-markdown": "^5.0.3", "scripting-tools": "^0.19.13", "tsafe": "^0.10.1", - "tss-react": "^3.7.1" + "tss-react": "^3.7.1", + "zod": "^3.17.10" } } diff --git a/src/bin/build-keycloak-theme/BuildOptions.ts b/src/bin/build-keycloak-theme/BuildOptions.ts new file mode 100644 index 00000000..43aa8fff --- /dev/null +++ b/src/bin/build-keycloak-theme/BuildOptions.ts @@ -0,0 +1,177 @@ +import { z } from "zod"; +import { assert } from "tsafe/assert"; +import type { Equals } from "tsafe"; +import { id } from "tsafe/id"; +import { parse as urlParse } from "url"; + +type ParsedPackageJson = { + name: string; + version: string; + homepage?: string; + keycloakify?: { + extraPages?: string[]; + extraThemeProperties?: string[]; + isAppAndKeycloakServerSharingSameDomain?: boolean; + }; +}; + +const zParsedPackageJson = z.object({ + "name": z.string(), + "version": z.string(), + "homepage": z.string().optional(), + "keycloakify": z + .object({ + "extraPages": z.array(z.string()).optional(), + "extraThemeProperties": z.array(z.string()).optional(), + "isAppAndKeycloakServerSharingSameDomain": z.boolean().optional(), + }) + .optional(), +}); + +assert, ParsedPackageJson>>(); + +/** Consolidated build option gathered form CLI arguments and config in package.json */ +export type BuildOptions = BuildOptions.Standalone | BuildOptions.ExternalAssets; + +export namespace BuildOptions { + export type Common = { + version: string; + themeName: string; + extraPages?: string[]; + extraThemeProperties?: string[]; + //NOTE: Only for the pom.xml file, questionable utility... + groupId: string; + }; + + export type Standalone = Common & { + isStandalone: true; + urlPathname: string | undefined; + }; + + export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains; + + export namespace ExternalAssets { + export type CommonExternalAssets = Common & { + isStandalone: false; + }; + + export type SameDomain = CommonExternalAssets & { + isAppAndKeycloakServerSharingSameDomain: true; + }; + + export type DifferentDomains = CommonExternalAssets & { + isAppAndKeycloakServerSharingSameDomain: false; + urlOrigin: string; + urlPathname: string | undefined; + }; + } +} + +export function readBuildOptions(params: { + packageJson: string; + CNAME: string | undefined; + isExternalAssetsCliParamProvided: boolean; +}): BuildOptions { + const { packageJson, CNAME, isExternalAssetsCliParamProvided } = params; + + const parsedPackageJson = zParsedPackageJson.parse(JSON.parse(packageJson)); + + const url = (() => { + const { homepage } = parsedPackageJson; + + let url: URL | undefined = undefined; + + if (homepage !== undefined) { + url = new URL(homepage); + } + + if (CNAME !== undefined) { + url = new URL(`https://${CNAME.replace(/\s+$/, "")}`); + } + + if (url === undefined) { + return undefined; + } + + return { + "origin": url.origin, + "pathname": (() => { + const out = url.pathname.replace(/([^/])$/, "$1/"); + + return out === "/" ? undefined : out; + })(), + }; + })(); + + const common: BuildOptions.Common = (() => { + const { name, keycloakify = {}, version, homepage } = parsedPackageJson; + + const { extraPages, extraThemeProperties } = keycloakify ?? {}; + + const themeName = name + .replace(/^@(.*)/, "$1") + .split("/") + .join("-"); + + return { + themeName, + "groupId": (() => { + const fallbackGroupId = `${themeName}.keycloak`; + + return ( + (!homepage + ? fallbackGroupId + : urlParse(homepage) + .host?.replace(/:[0-9]+$/, "") + ?.split(".") + .reverse() + .join(".") ?? fallbackGroupId) + ".keycloak" + ); + })(), + "version": version, + extraPages, + extraThemeProperties, + }; + })(); + + if (isExternalAssetsCliParamProvided) { + const commonExternalAssets = id({ + ...common, + "isStandalone": false, + }); + + if (parsedPackageJson.keycloakify?.isAppAndKeycloakServerSharingSameDomain) { + return id({ + ...commonExternalAssets, + "isAppAndKeycloakServerSharingSameDomain": true, + }); + } else { + assert( + url !== undefined, + [ + "Can't compile in external assets mode if we don't know where", + "the app will be hosted.", + "You should provide a homepage field in the package.json (or create a", + "public/CNAME file.", + "Alternatively, if your app and the Keycloak server are on the same domain, ", + "eg https://example.com is your app and https://example.com/auth is the keycloak", + 'admin UI, you can set "keycloakify": { "isAppAndKeycloakServerSharingSameDomain": true }', + "in your package.json", + ].join(" "), + ); + + return id({ + ...commonExternalAssets, + "isAppAndKeycloakServerSharingSameDomain": false, + "urlOrigin": url.origin, + "urlPathname": url.pathname, + }); + } + } + + return id({ + ...common, + "isStandalone": true, + "urlPathname": url?.pathname, + }); +} diff --git a/src/bin/build-keycloak-theme/build-keycloak-theme.ts b/src/bin/build-keycloak-theme/build-keycloak-theme.ts index 7e1ad68b..90dc60fa 100644 --- a/src/bin/build-keycloak-theme/build-keycloak-theme.ts +++ b/src/bin/build-keycloak-theme/build-keycloak-theme.ts @@ -3,76 +3,28 @@ import { generateJavaStackFiles } from "./generateJavaStackFiles"; import { join as pathJoin, relative as pathRelative, basename as pathBasename } from "path"; import * as child_process from "child_process"; import { generateStartKeycloakTestingContainer } from "./generateStartKeycloakTestingContainer"; -import { URL } from "url"; import * as fs from "fs"; - -type ParsedPackageJson = { - name: string; - version: string; - homepage?: string; -}; +import { readBuildOptions } from "./BuildOptions"; const reactProjectDirPath = process.cwd(); -const doUseExternalAssets = process.argv[2]?.toLowerCase() === "--external-assets"; - -const parsedPackageJson: ParsedPackageJson = require(pathJoin(reactProjectDirPath, "package.json")); - export const keycloakThemeBuildingDirPath = pathJoin(reactProjectDirPath, "build_keycloak"); export const keycloakThemeEmailDirPath = pathJoin(keycloakThemeBuildingDirPath, "..", "keycloak_email"); -function sanitizeThemeName(name: string) { - return name - .replace(/^@(.*)/, "$1") - .split("/") - .join("-"); -} - export function main() { console.log("🔏 Building the keycloak theme...⌚"); - const extraPagesId: string[] = (parsedPackageJson as any)["keycloakify"]?.["extraPages"] ?? []; - const extraThemeProperties: string[] = (parsedPackageJson as any)["keycloakify"]?.["extraThemeProperties"] ?? []; - const themeName = sanitizeThemeName(parsedPackageJson.name); + const buildOptions = readBuildOptions({ + "packageJson": fs.readFileSync(pathJoin(reactProjectDirPath, "")).toString("utf8"), + "CNAME": fs.readFileSync(pathJoin(reactProjectDirPath, "public", "CNAME")).toString("utf8"), + "isExternalAssetsCliParamProvided": process.argv[2]?.toLowerCase() === "--external-assets", + }); - const { doBundleEmailTemplate } = generateKeycloakThemeResources({ + const { doBundlesEmailTemplate } = generateKeycloakThemeResources({ keycloakThemeBuildingDirPath, keycloakThemeEmailDirPath, "reactAppBuildDirPath": pathJoin(reactProjectDirPath, "build"), - themeName, - ...(() => { - const url = (() => { - const { homepage } = parsedPackageJson; - - if (homepage !== undefined) { - return new URL(homepage); - } - - const cnameFilePath = pathJoin(reactProjectDirPath, "public", "CNAME"); - - if (fs.existsSync(cnameFilePath)) { - return new URL(`https://${fs.readFileSync(cnameFilePath).toString("utf8").replace(/\s+$/, "")}`); - } - - return undefined; - })(); - - return { - "urlPathname": url === undefined ? "/" : url.pathname.replace(/([^/])$/, "$1/"), - "urlOrigin": !doUseExternalAssets - ? undefined - : (() => { - if (url === undefined) { - console.error("ERROR: You must specify 'homepage' in your package.json"); - process.exit(-1); - } - - return url.origin; - })(), - }; - })(), - extraPagesId, - extraThemeProperties, + buildOptions, //We have to leave it at that otherwise we break our default theme. //Problem is that we can't guarantee that the the old resources //will still be available on the newer keycloak version. @@ -80,11 +32,10 @@ export function main() { }); const { jarFilePath } = generateJavaStackFiles({ - "version": parsedPackageJson.version, - themeName, - "homepage": parsedPackageJson.homepage, + "version": buildOptions.version, keycloakThemeBuildingDirPath, - doBundleEmailTemplate, + doBundlesEmailTemplate, + buildOptions, }); child_process.execSync("mvn package", { @@ -96,8 +47,8 @@ export function main() { generateStartKeycloakTestingContainer({ keycloakThemeBuildingDirPath, - themeName, "keycloakVersion": containerKeycloakVersion, + buildOptions, }); console.log( @@ -145,7 +96,7 @@ export function main() { "- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈", '- Create a realm named "myrealm"', '- Create a client with ID: "myclient", "Root URL": "https://www.keycloak.org/app/" and "Valid redirect URIs": "https://www.keycloak.org/app/*"', - `- Select Login Theme: ${themeName} (don't forget to save at the bottom of the page)`, + `- Select Login Theme: ${buildOptions.themeName} (don't forget to save at the bottom of the page)`, `- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`, "", "Video demoing this process: https://youtu.be/N3wlBoH4hKg", diff --git a/src/bin/build-keycloak-theme/generateFtl/generateFtl.ts b/src/bin/build-keycloak-theme/generateFtl/generateFtl.ts index 51ed807e..99c907b7 100644 --- a/src/bin/build-keycloak-theme/generateFtl/generateFtl.ts +++ b/src/bin/build-keycloak-theme/generateFtl/generateFtl.ts @@ -1,9 +1,14 @@ import cheerio from "cheerio"; -import { replaceImportsFromStaticInJsCode, replaceImportsInInlineCssCode, generateCssCodeToDefineGlobals } from "../replaceImportFromStatic"; +import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode"; +import { generateCssCodeToDefineGlobals } from "../replacers/replaceImportsInCssCode"; +import { replaceImportsInInlineCssCode } from "../replacers/replaceImportsInInlineCssCode"; import * as fs from "fs"; import { join as pathJoin } from "path"; import { objectKeys } from "tsafe/objectKeys"; import { ftlValuesGlobalName } from "../ftlValuesGlobalName"; +import type { BuildOptions } from "../BuildOptions"; +import { assert } from "tsafe/assert"; +import { Reflect } from "tsafe/Reflect"; // https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java export const pageIds = [ @@ -25,58 +30,111 @@ export const pageIds = [ "logout-confirm.ftl", ] as const; +export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets; + +export namespace BuildOptionsLike { + export type Standalone = { + isStandalone: true; + urlPathname: string | undefined; + }; + + export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains; + + export namespace ExternalAssets { + export type CommonExternalAssets = { + isStandalone: false; + }; + + export type SameDomain = CommonExternalAssets & { + isAppAndKeycloakServerSharingSameDomain: true; + }; + + export type DifferentDomains = CommonExternalAssets & { + isAppAndKeycloakServerSharingSameDomain: false; + urlOrigin: string; + urlPathname: string | undefined; + }; + } +} + +{ + const buildOptions = Reflect(); + + assert(); +} + export type PageId = typeof pageIds[number]; export function generateFtlFilesCodeFactory(params: { - cssGlobalsToDefine: Record; indexHtmlCode: string; - urlPathname: string; - urlOrigin: undefined | string; + //NOTE: Expected to be an empty object if external assets mode is enabled. + cssGlobalsToDefine: Record; + buildOptions: BuildOptionsLike; }) { - const { cssGlobalsToDefine, indexHtmlCode, urlPathname, urlOrigin } = params; + const { cssGlobalsToDefine, indexHtmlCode, buildOptions } = params; const $ = cheerio.load(indexHtmlCode); - $("script:not([src])").each((...[, element]) => { - const { fixedJsCode } = replaceImportsFromStaticInJsCode({ - "jsCode": $(element).html()!, - urlOrigin, + fix_imports_statements: { + if (!buildOptions.isStandalone && buildOptions.isAppAndKeycloakServerSharingSameDomain) { + break fix_imports_statements; + } + + $("script:not([src])").each((...[, element]) => { + const { fixedJsCode } = replaceImportsFromStaticInJsCode({ + "jsCode": $(element).html()!, + buildOptions, + }); + + $(element).text(fixedJsCode); }); - $(element).text(fixedJsCode); - }); + $("style").each((...[, element]) => { + const { fixedCssCode } = replaceImportsInInlineCssCode({ + "cssCode": $(element).html()!, + buildOptions, + }); - $("style").each((...[, element]) => { - const { fixedCssCode } = replaceImportsInInlineCssCode({ - "cssCode": $(element).html()!, - "urlPathname": params.urlPathname, - urlOrigin, + $(element).text(fixedCssCode); }); - $(element).text(fixedCssCode); - }); + ( + [ + ["link", "href"], + ["script", "src"], + ] as const + ).forEach(([selector, attrName]) => + $(selector).each((...[, element]) => { + const href = $(element).attr(attrName); - ( - [ - ["link", "href"], - ["script", "src"], - ] as const - ).forEach(([selector, attrName]) => - $(selector).each((...[, element]) => { - const href = $(element).attr(attrName); + if (href === undefined) { + return; + } - if (href === undefined) { - return; - } + $(element).attr( + attrName, + buildOptions.isStandalone + ? href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/") + : href.replace(/^\//, `${buildOptions.urlOrigin}/`), + ); + }), + ); - $(element).attr( - attrName, - urlOrigin !== undefined - ? href.replace(/^\//, `${urlOrigin}/`) - : href.replace(new RegExp(`^${urlPathname.replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/"), + if (Object.keys(cssGlobalsToDefine).length !== 0) { + $("head").prepend( + [ + "", + "", + "", + ].join("\n"), ); - }), - ); + } + } //FTL is no valid html, we can't insert with cheerio, we put placeholder for injecting later. const replaceValueBySearchValue = { @@ -95,18 +153,6 @@ export function generateFtlFilesCodeFactory(params: { $("head").prepend( [ - ...(Object.keys(cssGlobalsToDefine).length === 0 - ? [] - : [ - "", - "", - "", - ]), "", diff --git a/src/bin/build-keycloak-theme/generateJavaStackFiles.ts b/src/bin/build-keycloak-theme/generateJavaStackFiles.ts index b0f56946..578a292d 100644 --- a/src/bin/build-keycloak-theme/generateJavaStackFiles.ts +++ b/src/bin/build-keycloak-theme/generateJavaStackFiles.ts @@ -1,37 +1,39 @@ -import * as url from "url"; import * as fs from "fs"; import { join as pathJoin, dirname as pathDirname } from "path"; +import { assert } from "tsafe/assert"; +import { Reflect } from "tsafe/Reflect"; +import type { BuildOptions } from "./BuildOptions"; + +export type BuildOptionsLike = { + themeName: string; + groupId: string; +}; + +{ + const buildOptions = Reflect(); + + assert(); +} export function generateJavaStackFiles(params: { version: string; - themeName: string; - homepage?: string; keycloakThemeBuildingDirPath: string; - doBundleEmailTemplate: boolean; + doBundlesEmailTemplate: boolean; + buildOptions: BuildOptionsLike; }): { jarFilePath: string; } { - const { themeName, version, homepage, keycloakThemeBuildingDirPath, doBundleEmailTemplate } = params; + const { + version, + buildOptions: { groupId, themeName }, + keycloakThemeBuildingDirPath, + doBundlesEmailTemplate, + } = params; { const { pomFileCode } = (function generatePomFileCode(): { pomFileCode: string; } { - const groupId = (() => { - const fallbackGroupId = `there.was.no.homepage.field.in.the.package.json.${themeName}`; - - return ( - (!homepage - ? fallbackGroupId - : url - .parse(homepage) - .host?.replace(/:[0-9]+$/, "") - ?.split(".") - .reverse() - .join(".") ?? fallbackGroupId) + ".keycloak" - ); - })(); - const artefactId = `${themeName}-keycloak-theme`; const pomFileCode = [ @@ -69,7 +71,7 @@ export function generateJavaStackFiles(params: { "themes": [ { "name": themeName, - "types": ["login", ...(doBundleEmailTemplate ? ["email"] : [])], + "types": ["login", ...(doBundlesEmailTemplate ? ["email"] : [])], }, ], }, diff --git a/src/bin/build-keycloak-theme/generateKeycloakThemeResources.ts b/src/bin/build-keycloak-theme/generateKeycloakThemeResources.ts index 7b2acd68..a77ba9b3 100644 --- a/src/bin/build-keycloak-theme/generateKeycloakThemeResources.ts +++ b/src/bin/build-keycloak-theme/generateKeycloakThemeResources.ts @@ -1,48 +1,76 @@ import { transformCodebase } from "../tools/transformCodebase"; import * as fs from "fs"; import { join as pathJoin, basename as pathBasename } from "path"; -import { replaceImportsInCssCode, replaceImportsFromStaticInJsCode } from "./replaceImportFromStatic"; +import { replaceImportsFromStaticInJsCode } from "./replacers/replaceImportsFromStaticInJsCode"; +import { replaceImportsInCssCode } from "./replacers/replaceImportsInCssCode"; import { generateFtlFilesCodeFactory, pageIds } from "./generateFtl"; import { downloadBuiltinKeycloakTheme } from "../download-builtin-keycloak-theme"; import * as child_process from "child_process"; import { mockTestingResourcesCommonPath, mockTestingResourcesPath, mockTestingSubDirOfPublicDirBasename } from "../mockTestingResourcesPath"; import { isInside } from "../tools/isInside"; +import type { BuildOptions } from "./BuildOptions"; +import { assert } from "tsafe/assert"; +import { Reflect } from "tsafe/Reflect"; + +export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets; + +export namespace BuildOptionsLike { + export type Common = { + themeName: string; + extraPages?: string[]; + extraThemeProperties?: string[]; + }; + + export type Standalone = Common & { + isStandalone: true; + urlPathname: string | undefined; + }; + + export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains; + + export namespace ExternalAssets { + export type CommonExternalAssets = Common & { + isStandalone: false; + }; + + export type SameDomain = CommonExternalAssets & { + isAppAndKeycloakServerSharingSameDomain: true; + }; + + export type DifferentDomains = CommonExternalAssets & { + isAppAndKeycloakServerSharingSameDomain: false; + urlOrigin: string; + urlPathname: string | undefined; + }; + } +} + +{ + const buildOptions = Reflect(); + + assert(); +} export function generateKeycloakThemeResources(params: { - themeName: string; reactAppBuildDirPath: string; keycloakThemeBuildingDirPath: string; keycloakThemeEmailDirPath: string; - urlPathname: string; - //If urlOrigin is not undefined then it means --externals-assets - urlOrigin: undefined | string; - extraPagesId: string[]; - extraThemeProperties: string[]; keycloakVersion: string; -}): { doBundleEmailTemplate: boolean } { - const { - themeName, - reactAppBuildDirPath, - keycloakThemeBuildingDirPath, - keycloakThemeEmailDirPath, - urlPathname, - urlOrigin, - extraPagesId, - extraThemeProperties, - keycloakVersion, - } = params; + buildOptions: BuildOptionsLike; +}): { doBundlesEmailTemplate: boolean } { + const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, keycloakThemeEmailDirPath, keycloakVersion, buildOptions } = params; - const themeDirPath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName, "login"); + const themeDirPath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, "login"); let allCssGlobalsToDefine: Record = {}; transformCodebase({ - "destDirPath": urlOrigin === undefined ? pathJoin(themeDirPath, "resources", "build") : reactAppBuildDirPath, + "destDirPath": buildOptions.isStandalone ? pathJoin(themeDirPath, "resources", "build") : reactAppBuildDirPath, "srcDirPath": reactAppBuildDirPath, "transformSourceCode": ({ filePath, sourceCode }) => { //NOTE: Prevent cycles, excludes the folder we generated for debug in public/ if ( - urlOrigin === undefined && + buildOptions.isStandalone && isInside({ "dirPath": pathJoin(reactAppBuildDirPath, mockTestingSubDirOfPublicDirBasename), filePath, @@ -51,7 +79,11 @@ export function generateKeycloakThemeResources(params: { return undefined; } - if (urlOrigin === undefined && /\.css?$/i.test(filePath)) { + if (/\.css?$/i.test(filePath)) { + if (!buildOptions.isStandalone) { + return undefined; + } + const { cssGlobalsToDefine, fixedCssCode } = replaceImportsInCssCode({ "cssCode": sourceCode.toString("utf8"), }); @@ -61,27 +93,27 @@ export function generateKeycloakThemeResources(params: { ...cssGlobalsToDefine, }; - return { - "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8"), - }; + return { "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8") }; } if (/\.js?$/i.test(filePath)) { + if (!buildOptions.isStandalone && buildOptions.isAppAndKeycloakServerSharingSameDomain) { + return undefined; + } + const { fixedJsCode } = replaceImportsFromStaticInJsCode({ "jsCode": sourceCode.toString("utf8"), - urlOrigin, + buildOptions, }); - return { - "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8"), - }; + return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") }; } - return urlOrigin === undefined ? { "modifiedSourceCode": sourceCode } : undefined; + return buildOptions.isStandalone ? { "modifiedSourceCode": sourceCode } : undefined; }, }); - let doBundleEmailTemplate: boolean; + let doBundlesEmailTemplate: boolean; email: { if (!fs.existsSync(keycloakThemeEmailDirPath)) { @@ -91,11 +123,11 @@ export function generateKeycloakThemeResources(params: { `To start customizing the email template, run: 👉 npx create-keycloak-email-directory 👈`, ].join("\n"), ); - doBundleEmailTemplate = false; + doBundlesEmailTemplate = false; break email; } - doBundleEmailTemplate = true; + doBundlesEmailTemplate = true; transformCodebase({ "srcDirPath": keycloakThemeEmailDirPath, @@ -104,13 +136,12 @@ export function generateKeycloakThemeResources(params: { } const { generateFtlFilesCode } = generateFtlFilesCodeFactory({ - "cssGlobalsToDefine": allCssGlobalsToDefine, "indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"), - urlPathname, - urlOrigin, + "cssGlobalsToDefine": allCssGlobalsToDefine, + "buildOptions": buildOptions, }); - [...pageIds, ...extraPagesId].forEach(pageId => { + [...pageIds, ...(buildOptions.extraPages ?? [])].forEach(pageId => { const { ftlCode } = generateFtlFilesCode({ pageId }); fs.mkdirSync(themeDirPath, { "recursive": true }); @@ -161,8 +192,8 @@ export function generateKeycloakThemeResources(params: { fs.writeFileSync( pathJoin(themeDirPath, "theme.properties"), - Buffer.from("parent=keycloak".concat("\n\n", extraThemeProperties.join("\n\n")), "utf8"), + Buffer.from(["parent=keycloak", ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8"), ); - return { doBundleEmailTemplate }; + return { doBundlesEmailTemplate }; } diff --git a/src/bin/build-keycloak-theme/generateStartKeycloakTestingContainer.ts b/src/bin/build-keycloak-theme/generateStartKeycloakTestingContainer.ts index f30873e7..1c0dd707 100644 --- a/src/bin/build-keycloak-theme/generateStartKeycloakTestingContainer.ts +++ b/src/bin/build-keycloak-theme/generateStartKeycloakTestingContainer.ts @@ -1,13 +1,34 @@ import * as fs from "fs"; import { join as pathJoin } from "path"; +import { assert } from "tsafe/assert"; +import { Reflect } from "tsafe/Reflect"; +import type { BuildOptions } from "./BuildOptions"; + +export type BuildOptionsLike = { + themeName: string; +}; + +{ + const buildOptions = Reflect(); + + assert(); +} generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh"; const containerName = "keycloak-testing-container"; /** Files for being able to run a hot reload keycloak container */ -export function generateStartKeycloakTestingContainer(params: { keycloakVersion: string; themeName: string; keycloakThemeBuildingDirPath: string }) { - const { themeName, keycloakThemeBuildingDirPath, keycloakVersion } = params; +export function generateStartKeycloakTestingContainer(params: { + keycloakVersion: string; + keycloakThemeBuildingDirPath: string; + buildOptions: BuildOptionsLike; +}) { + const { + keycloakThemeBuildingDirPath, + keycloakVersion, + buildOptions: { themeName }, + } = params; fs.writeFileSync( pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename), diff --git a/src/bin/build-keycloak-theme/replaceImportFromStatic.ts b/src/bin/build-keycloak-theme/replaceImportFromStatic.ts deleted file mode 100644 index 9f0eb109..00000000 --- a/src/bin/build-keycloak-theme/replaceImportFromStatic.ts +++ /dev/null @@ -1,119 +0,0 @@ -import * as crypto from "crypto"; -import { ftlValuesGlobalName } from "./ftlValuesGlobalName"; - -export function replaceImportsFromStaticInJsCode(params: { jsCode: string; urlOrigin: undefined | string }): { fixedJsCode: string } { - /* - NOTE: - - When we have urlOrigin defined it means that - we are building with --external-assets - so we have to make sur that the fixed js code will run - inside and outside keycloak. - - When urlOrigin isn't defined we can assume the fixedJsCode - will always run in keycloak context. - */ - - const { jsCode, urlOrigin } = params; - - const getReplaceArgs = (language: "js" | "css"): Parameters => [ - new RegExp(`([a-zA-Z]+)\\.([a-zA-Z]+)=function\\(([a-zA-Z]+)\\){return"static\\/${language}\\/"`, "g"), - (...[, n, u, e]) => ` - ${n}[(function(){ - ${ - urlOrigin === undefined - ? ` - Object.defineProperty(${n}, "p", { - get: function() { return window.${ftlValuesGlobalName}.url.resourcesPath; }, - set: function (){} - }); - ` - : ` - var p= ""; - Object.defineProperty(${n}, "p", { - get: function() { return ("${ftlValuesGlobalName}" in window ? "${urlOrigin}" : "") + p; }, - set: function (value){ p = value;} - }); - ` - } - return "${u}"; - })()] = function(${e}) { return "${urlOrigin === undefined ? "/build/" : ""}static/${language}/"`, - ]; - - const fixedJsCode = jsCode - .replace(...getReplaceArgs("js")) - .replace(...getReplaceArgs("css")) - .replace(/([a-zA-Z]+\.[a-zA-Z]+)\+"static\//g, (...[, group]) => - urlOrigin === undefined - ? `window.${ftlValuesGlobalName}.url.resourcesPath + "/build/static/` - : `("${ftlValuesGlobalName}" in window ? "${urlOrigin}" : "") + ${group} + "static/`, - ) - //TODO: Write a test case for this - .replace(/".chunk.css",([a-zA-Z])+=([a-zA-Z]+\.[a-zA-Z]+)\+([a-zA-Z]+),/, (...[, group1, group2, group3]) => - urlOrigin === undefined - ? `".chunk.css",${group1} = window.${ftlValuesGlobalName}.url.resourcesPath + "/build/" + ${group3},` - : `".chunk.css",${group1} = ("${ftlValuesGlobalName}" in window ? "${urlOrigin}" : "") + ${group2} + ${group3},`, - ); - - return { fixedJsCode }; -} - -export function replaceImportsInInlineCssCode(params: { cssCode: string; urlPathname: string; urlOrigin: undefined | string }): { - fixedCssCode: string; -} { - const { cssCode, urlPathname, urlOrigin } = params; - - const fixedCssCode = cssCode.replace( - urlPathname === "/" ? /url\(["']?\/([^/][^)"']+)["']?\)/g : new RegExp(`url\\(["']?${urlPathname}([^)"']+)["']?\\)`, "g"), - (...[, group]) => `url(${urlOrigin === undefined ? "${url.resourcesPath}/build/" + group : params.urlOrigin + urlPathname + group})`, - ); - - return { fixedCssCode }; -} - -export function replaceImportsInCssCode(params: { cssCode: string }): { - fixedCssCode: string; - cssGlobalsToDefine: Record; -} { - const { cssCode } = params; - - const cssGlobalsToDefine: Record = {}; - - new Set(cssCode.match(/url\(["']?\/[^/][^)"']+["']?\)[^;}]*/g) ?? []).forEach( - match => (cssGlobalsToDefine["url" + crypto.createHash("sha256").update(match).digest("hex").substring(0, 15)] = match), - ); - - let fixedCssCode = cssCode; - - Object.keys(cssGlobalsToDefine).forEach( - cssVariableName => - //NOTE: split/join pattern ~ replace all - (fixedCssCode = fixedCssCode.split(cssGlobalsToDefine[cssVariableName]).join(`var(--${cssVariableName})`)), - ); - - return { fixedCssCode, cssGlobalsToDefine }; -} - -export function generateCssCodeToDefineGlobals(params: { cssGlobalsToDefine: Record; urlPathname: string }): { - cssCodeToPrependInHead: string; -} { - const { cssGlobalsToDefine, urlPathname } = params; - - return { - "cssCodeToPrependInHead": [ - ":root {", - ...Object.keys(cssGlobalsToDefine) - .map(cssVariableName => - [ - `--${cssVariableName}:`, - cssGlobalsToDefine[cssVariableName].replace( - new RegExp(`url\\(${urlPathname.replace(/\//g, "\\/")}`, "g"), - "url(${url.resourcesPath}/build/", - ), - ].join(" "), - ) - .map(line => ` ${line};`), - "}", - ].join("\n"), - }; -} diff --git a/src/bin/build-keycloak-theme/replacers/replaceImportsFromStaticInJsCode.ts b/src/bin/build-keycloak-theme/replacers/replaceImportsFromStaticInJsCode.ts new file mode 100644 index 00000000..31133a65 --- /dev/null +++ b/src/bin/build-keycloak-theme/replacers/replaceImportsFromStaticInJsCode.ts @@ -0,0 +1,83 @@ +import { ftlValuesGlobalName } from "../ftlValuesGlobalName"; +import type { BuildOptions } from "../BuildOptions"; +import { assert } from "tsafe/assert"; +import { is } from "tsafe/is"; +import { Reflect } from "tsafe/Reflect"; + +export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets; + +export namespace BuildOptionsLike { + export type Standalone = { + isStandalone: true; + }; + + export type ExternalAssets = { + isStandalone: false; + urlOrigin: string; + }; +} + +{ + const buildOptions = Reflect(); + + assert(!is(buildOptions)); + + assert(); +} + +export function replaceImportsFromStaticInJsCode(params: { jsCode: string; buildOptions: BuildOptionsLike }): { fixedJsCode: string } { + /* + NOTE: + + When we have urlOrigin defined it means that + we are building with --external-assets + so we have to make sur that the fixed js code will run + inside and outside keycloak. + + When urlOrigin isn't defined we can assume the fixedJsCode + will always run in keycloak context. + */ + + const { jsCode, buildOptions } = params; + + const getReplaceArgs = (language: "js" | "css"): Parameters => [ + new RegExp(`([a-zA-Z]+)\\.([a-zA-Z]+)=function\\(([a-zA-Z]+)\\){return"static\\/${language}\\/"`, "g"), + (...[, n, u, e]) => ` + ${n}[(function(){ + ${ + buildOptions.isStandalone + ? ` + Object.defineProperty(${n}, "p", { + get: function() { return window.${ftlValuesGlobalName}.url.resourcesPath; }, + set: function (){} + }); + ` + : ` + var p= ""; + Object.defineProperty(${n}, "p", { + get: function() { return ("${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}" : "") + p; }, + set: function (value){ p = value;} + }); + ` + } + return "${u}"; + })()] = function(${e}) { return "${buildOptions.isStandalone ? "/build/" : ""}static/${language}/"`, + ]; + + const fixedJsCode = jsCode + .replace(...getReplaceArgs("js")) + .replace(...getReplaceArgs("css")) + .replace(/([a-zA-Z]+\.[a-zA-Z]+)\+"static\//g, (...[, group]) => + buildOptions.isStandalone + ? `window.${ftlValuesGlobalName}.url.resourcesPath + "/build/static/` + : `("${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}" : "") + ${group} + "static/`, + ) + //TODO: Write a test case for this + .replace(/".chunk.css",([a-zA-Z])+=([a-zA-Z]+\.[a-zA-Z]+)\+([a-zA-Z]+),/, (...[, group1, group2, group3]) => + buildOptions.isStandalone + ? `".chunk.css",${group1} = window.${ftlValuesGlobalName}.url.resourcesPath + "/build/" + ${group3},` + : `".chunk.css",${group1} = ("${ftlValuesGlobalName}" in window ? "${buildOptions.urlOrigin}" : "") + ${group2} + ${group3},`, + ); + + return { fixedJsCode }; +} diff --git a/src/bin/build-keycloak-theme/replacers/replaceImportsInCssCode.ts b/src/bin/build-keycloak-theme/replacers/replaceImportsInCssCode.ts new file mode 100644 index 00000000..9f51103e --- /dev/null +++ b/src/bin/build-keycloak-theme/replacers/replaceImportsInCssCode.ts @@ -0,0 +1,64 @@ +import * as crypto from "crypto"; +import type { BuildOptions } from "../BuildOptions"; +import { assert } from "tsafe/assert"; +import { is } from "tsafe/is"; +import { Reflect } from "tsafe/Reflect"; + +export type BuildOptionsLike = { + urlPathname: string | undefined; +}; + +{ + const buildOptions = Reflect(); + + assert(!is(buildOptions)); + + assert(); +} + +export function replaceImportsInCssCode(params: { cssCode: string }): { + fixedCssCode: string; + cssGlobalsToDefine: Record; +} { + const { cssCode } = params; + + const cssGlobalsToDefine: Record = {}; + + new Set(cssCode.match(/url\(["']?\/[^/][^)"']+["']?\)[^;}]*/g) ?? []).forEach( + match => (cssGlobalsToDefine["url" + crypto.createHash("sha256").update(match).digest("hex").substring(0, 15)] = match), + ); + + let fixedCssCode = cssCode; + + Object.keys(cssGlobalsToDefine).forEach( + cssVariableName => + //NOTE: split/join pattern ~ replace all + (fixedCssCode = fixedCssCode.split(cssGlobalsToDefine[cssVariableName]).join(`var(--${cssVariableName})`)), + ); + + return { fixedCssCode, cssGlobalsToDefine }; +} + +export function generateCssCodeToDefineGlobals(params: { cssGlobalsToDefine: Record; buildOptions: BuildOptionsLike }): { + cssCodeToPrependInHead: string; +} { + const { cssGlobalsToDefine, buildOptions } = params; + + return { + "cssCodeToPrependInHead": [ + ":root {", + ...Object.keys(cssGlobalsToDefine) + .map(cssVariableName => + [ + `--${cssVariableName}:`, + cssGlobalsToDefine[cssVariableName].replace( + new RegExp(`url\\(${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`, "g"), + "url(${url.resourcesPath}/build/", + ), + ].join(" "), + ) + .map(line => ` ${line};`), + "}", + ].join("\n"), + }; +} diff --git a/src/bin/build-keycloak-theme/replacers/replaceImportsInInlineCssCode.ts b/src/bin/build-keycloak-theme/replacers/replaceImportsInInlineCssCode.ts new file mode 100644 index 00000000..376a5bcf --- /dev/null +++ b/src/bin/build-keycloak-theme/replacers/replaceImportsInInlineCssCode.ts @@ -0,0 +1,47 @@ +import type { BuildOptions } from "../BuildOptions"; +import { assert } from "tsafe/assert"; +import { is } from "tsafe/is"; +import { Reflect } from "tsafe/Reflect"; + +export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets; + +export namespace BuildOptionsLike { + export type Common = { + urlPathname: string | undefined; + }; + + export type Standalone = Common & { + isStandalone: true; + }; + + export type ExternalAssets = Common & { + isStandalone: false; + urlOrigin: string; + }; +} + +{ + const buildOptions = Reflect(); + + assert(!is(buildOptions)); + + assert(); +} + +export function replaceImportsInInlineCssCode(params: { cssCode: string; buildOptions: BuildOptionsLike }): { + fixedCssCode: string; +} { + const { cssCode, buildOptions } = params; + + const fixedCssCode = cssCode.replace( + buildOptions.urlPathname === undefined + ? /url\(["']?\/([^/][^)"']+)["']?\)/g + : new RegExp(`url\\(["']?${buildOptions.urlPathname}([^)"']+)["']?\\)`, "g"), + (...[, group]) => + `url(${ + buildOptions.isStandalone ? "${url.resourcesPath}/build/" + group : buildOptions.urlOrigin + (buildOptions.urlPathname ?? "/") + group + })`, + ); + + return { fixedCssCode }; +} diff --git a/src/test/bin/generateKeycloakThemeResources.ts b/src/test/bin/generateKeycloakThemeResources.ts index b354ed4a..927e3f98 100644 --- a/src/test/bin/generateKeycloakThemeResources.ts +++ b/src/test/bin/generateKeycloakThemeResources.ts @@ -5,13 +5,15 @@ import { setupSampleReactProject, sampleReactProjectDirPath } from "./setupSampl setupSampleReactProject(); generateKeycloakThemeResources({ - "themeName": "keycloakify-demo-app", "reactAppBuildDirPath": pathJoin(sampleReactProjectDirPath, "build"), "keycloakThemeBuildingDirPath": pathJoin(sampleReactProjectDirPath, "build_keycloak_theme"), "keycloakThemeEmailDirPath": pathJoin(sampleReactProjectDirPath, "keycloak_email"), - "urlPathname": "/keycloakify-demo-app/", - "urlOrigin": undefined, - "extraPagesId": ["my-custom-page.ftl"], - "extraThemeProperties": ["env=test"], "keycloakVersion": "11.0.3", + "buildOptions": { + "themeName": "keycloakify-demo-app", + "extraPages": ["my-custom-page.ftl"], + "extraThemeProperties": ["env=test"], + "isStandalone": true, + "urlPathname": "/keycloakify-demo-app/", + }, }); diff --git a/src/test/bin/replaceImportFromStatic.ts b/src/test/bin/replaceImportFromStatic.ts index a5aad440..70c52c8f 100644 --- a/src/test/bin/replaceImportFromStatic.ts +++ b/src/test/bin/replaceImportFromStatic.ts @@ -1,20 +1,10 @@ -import { - replaceImportsFromStaticInJsCode, - replaceImportsInInlineCssCode, - replaceImportsInCssCode, - generateCssCodeToDefineGlobals, -} from "../../bin/build-keycloak-theme/replaceImportFromStatic"; +import { replaceImportsFromStaticInJsCode } from "../../bin/build-keycloak-theme/replacers/replaceImportsFromStaticInJsCode"; +import { generateCssCodeToDefineGlobals, replaceImportsInCssCode } from "../../bin/build-keycloak-theme/replacers/replaceImportsInCssCode"; +import { replaceImportsInInlineCssCode } from "../../bin/build-keycloak-theme/replacers/replaceImportsInInlineCssCode"; import { assert } from "tsafe/assert"; import { same } from "evt/tools/inDepth/same"; import { assetIsSameCode } from "../tools/assertIsSameCode"; -/* - NOTES: - When not compiled with --external-assets urlOrigin will always be undefined regardless of the "homepage" field. - When compiled with --external-assets and we have a home page filed like "https://example.com" or "https://example.com/x/y/z" urlOrigin will be "https://example.com" - Regardless of if it's compiled with --external-assets or not, if "homepage" is like "https://example.com/x/y/z" urlPathname will be "/x/y/z/" -*/ - { const jsCodeUntransformed = ` function f() { @@ -46,7 +36,9 @@ import { assetIsSameCode } from "../tools/assertIsSameCode"; { const { fixedJsCode } = replaceImportsFromStaticInJsCode({ "jsCode": jsCodeUntransformed, - "urlOrigin": undefined, + "buildOptions": { + "isStandalone": true, + }, }); const fixedJsCodeExpected = ` @@ -97,7 +89,10 @@ import { assetIsSameCode } from "../tools/assertIsSameCode"; { const { fixedJsCode } = replaceImportsFromStaticInJsCode({ "jsCode": jsCodeUntransformed, - "urlOrigin": "https://demo-app.keycloakify.dev", + "buildOptions": { + "isStandalone": false, + "urlOrigin": "https://demo-app.keycloakify.dev", + }, }); const fixedJsCodeExpected = ` @@ -189,7 +184,9 @@ import { assetIsSameCode } from "../tools/assertIsSameCode"; const { cssCodeToPrependInHead } = generateCssCodeToDefineGlobals({ cssGlobalsToDefine, - "urlPathname": "/", + "buildOptions": { + "urlPathname": undefined, + }, }); const cssCodeToPrependInHeadExpected = ` @@ -244,7 +241,9 @@ import { assetIsSameCode } from "../tools/assertIsSameCode"; const { cssCodeToPrependInHead } = generateCssCodeToDefineGlobals({ cssGlobalsToDefine, - "urlPathname": "/x/y/z/", + "buildOptions": { + "urlPathname": "/x/y/z/", + }, }); const cssCodeToPrependInHeadExpected = ` @@ -292,8 +291,10 @@ import { assetIsSameCode } from "../tools/assertIsSameCode"; { const { fixedCssCode } = replaceImportsInInlineCssCode({ cssCode, - "urlOrigin": undefined, - "urlPathname": "/", + "buildOptions": { + "isStandalone": true, + "urlPathname": undefined, + }, }); const fixedCssCodeExpected = ` @@ -337,8 +338,11 @@ import { assetIsSameCode } from "../tools/assertIsSameCode"; { const { fixedCssCode } = replaceImportsInInlineCssCode({ cssCode, - "urlOrigin": "https://demo-app.keycloakify.dev", - "urlPathname": "/", + "buildOptions": { + "isStandalone": false, + "urlOrigin": "https://demo-app.keycloakify.dev", + "urlPathname": undefined, + }, }); const fixedCssCodeExpected = ` @@ -415,8 +419,10 @@ import { assetIsSameCode } from "../tools/assertIsSameCode"; { const { fixedCssCode } = replaceImportsInInlineCssCode({ cssCode, - "urlOrigin": undefined, - "urlPathname": "/x/y/z/", + "buildOptions": { + "isStandalone": true, + "urlPathname": "/x/y/z/", + }, }); const fixedCssCodeExpected = ` @@ -460,8 +466,11 @@ import { assetIsSameCode } from "../tools/assertIsSameCode"; { const { fixedCssCode } = replaceImportsInInlineCssCode({ cssCode, - "urlOrigin": "https://demo-app.keycloakify.dev", - "urlPathname": "/x/y/z/", + "buildOptions": { + "isStandalone": false, + "urlOrigin": "https://demo-app.keycloakify.dev", + "urlPathname": "/x/y/z/", + }, }); const fixedCssCodeExpected = ` diff --git a/yarn.lock b/yarn.lock index e0fb1c7f..7dbac281 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1914,3 +1914,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.17.10: + version "3.17.10" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.17.10.tgz#8716a05e6869df6faaa878a44ffe3c79e615defb" + integrity sha512-IHXnQYQuOOOL/XgHhgl8YjNxBHi3xX0mVcHmqsvJgcxKkEczPshoWdxqyFwsARpf41E0v9U95WUROqsHHxt0UQ==