diff --git a/scripts/generate-i18n-messages.ts b/scripts/generate-i18n-messages.ts index 3a8adcee..756cef4f 100644 --- a/scripts/generate-i18n-messages.ts +++ b/scripts/generate-i18n-messages.ts @@ -24,9 +24,9 @@ async function main() { fs.rmSync(tmpDirPath, { "recursive": true, "force": true }); await downloadBuiltinKeycloakTheme({ + "projectDirPath": getProjectRoot(), keycloakVersion, - "destDirPath": tmpDirPath, - isSilent + "destDirPath": tmpDirPath }); type Dictionary = { [idiomId: string]: string }; diff --git a/src/account/Template.tsx b/src/account/Template.tsx index 81597bd8..884b4a6a 100644 --- a/src/account/Template.tsx +++ b/src/account/Template.tsx @@ -17,9 +17,11 @@ export default function Template(props: TemplateProps) { const { isReady } = usePrepareTemplate({ "doFetchDefaultThemeResources": doUseDefaultCss, - url, - "stylesCommon": ["node_modules/patternfly/dist/css/patternfly.min.css", "node_modules/patternfly/dist/css/patternfly-additions.min.css"], - "styles": ["css/account.css"], + "styles": [ + `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`, + `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`, + `${url.resourcesPath}/css/account.css` + ], "htmlClassName": undefined, "bodyClassName": clsx("admin-console", "user", getClassName("kcBodyClass")) }); diff --git a/src/bin/copy-keycloak-resources-to-public.ts b/src/bin/copy-keycloak-resources-to-public.ts index f0049742..3fec0214 100644 --- a/src/bin/copy-keycloak-resources-to-public.ts +++ b/src/bin/copy-keycloak-resources-to-public.ts @@ -24,10 +24,11 @@ import * as fs from "fs"; for (const themeType of themeTypes) { await downloadKeycloakStaticResources({ - "isSilent": false, + projectDirPath, "keycloakVersion": buildOptions.keycloakVersionDefaultAssets, "themeType": themeType, - "themeDirPath": keycloakDirInPublicDir + "themeDirPath": keycloakDirInPublicDir, + "usedResources": undefined }); } diff --git a/src/bin/download-builtin-keycloak-theme.ts b/src/bin/download-builtin-keycloak-theme.ts index f260600b..db098063 100644 --- a/src/bin/download-builtin-keycloak-theme.ts +++ b/src/bin/download-builtin-keycloak-theme.ts @@ -4,19 +4,76 @@ import { downloadAndUnzip } from "./tools/downloadAndUnzip"; import { promptKeycloakVersion } from "./promptKeycloakVersion"; import { getLogger } from "./tools/logger"; import { readBuildOptions } from "./keycloakify/BuildOptions"; +import * as child_process from "child_process"; +import * as fs from "fs"; -export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: string; destDirPath: string; isSilent: boolean }) { - const { keycloakVersion, destDirPath } = params; +export async function downloadBuiltinKeycloakTheme(params: { projectDirPath: string; keycloakVersion: string; destDirPath: string }) { + const { projectDirPath, keycloakVersion, destDirPath } = params; - await Promise.all( - ["", "-community"].map(ext => - downloadAndUnzip({ - "destDirPath": destDirPath, - "url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`, - "pathOfDirToExtractInArchive": `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme` - }) - ) - ); + const start = Date.now(); + + await downloadAndUnzip({ + "doUseCache": true, + projectDirPath, + destDirPath, + "url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`, + "specificDirsToExtract": ["", "-community"].map(ext => `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`), + "preCacheTransform": { + "actionCacheId": "npm install and build", + "action": async ({ destDirPath }) => { + install_common_node_modules: { + const commonResourcesDirPath = pathJoin(destDirPath, "keycloak", "common", "resources"); + + if (!fs.existsSync(commonResourcesDirPath)) { + break install_common_node_modules; + } + + if (!fs.existsSync(pathJoin(commonResourcesDirPath, "package.json"))) { + break install_common_node_modules; + } + + if (fs.existsSync(pathJoin(commonResourcesDirPath, "node_modules"))) { + break install_common_node_modules; + } + + child_process.execSync("npm install --omit=dev", { + "cwd": commonResourcesDirPath, + "stdio": "ignore" + }); + } + + install_and_move_to_common_resources_generated_in_keycloak_v2: { + const accountV2DirSrcDirPath = pathJoin(destDirPath, "keycloak.v2", "account", "src"); + + if (!fs.existsSync(accountV2DirSrcDirPath)) { + break install_and_move_to_common_resources_generated_in_keycloak_v2; + } + + child_process.execSync("npm install", { "cwd": accountV2DirSrcDirPath, "stdio": "ignore" }); + + const packageJsonFilePath = pathJoin(accountV2DirSrcDirPath, "package.json"); + + const packageJsonRaw = fs.readFileSync(packageJsonFilePath); + + const parsedPackageJson = JSON.parse(packageJsonRaw.toString("utf8")); + + parsedPackageJson.scripts.build = parsedPackageJson.scripts.build + .replace("npm run check-types", "true") + .replace("npm run babel", "true"); + + fs.writeFileSync(packageJsonFilePath, Buffer.from(JSON.stringify(parsedPackageJson, null, 2), "utf8")); + + child_process.execSync("npm run build", { "cwd": accountV2DirSrcDirPath, "stdio": "ignore" }); + + fs.writeFileSync(packageJsonFilePath, packageJsonRaw); + + fs.rmSync(pathJoin(accountV2DirSrcDirPath, "node_modules"), { "recursive": true }); + } + } + } + }); + + console.log("Downloaded Keycloak theme in", Date.now() - start, "ms"); } async function main() { @@ -33,9 +90,9 @@ async function main() { logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`); await downloadBuiltinKeycloakTheme({ + "projectDirPath": process.cwd(), keycloakVersion, - destDirPath, - "isSilent": buildOptions.isSilent + destDirPath }); } diff --git a/src/bin/initialize-email-theme.ts b/src/bin/initialize-email-theme.ts index 04388049..8a81a5bb 100644 --- a/src/bin/initialize-email-theme.ts +++ b/src/bin/initialize-email-theme.ts @@ -10,15 +10,17 @@ import { getLogger } from "./tools/logger"; import { getThemeSrcDirPath } from "./getSrcDirPath"; export async function main() { + const projectDirPath = process.cwd(); + const { isSilent } = readBuildOptions({ - "projectDirPath": process.cwd(), + projectDirPath, "processArgv": process.argv.slice(2) }); const logger = getLogger({ isSilent }); const { themeSrcDirPath } = getThemeSrcDirPath({ - "projectDirPath": process.cwd() + projectDirPath }); const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email"); @@ -34,9 +36,9 @@ export async function main() { const builtinKeycloakThemeTmpDirPath = pathJoin(emailThemeSrcDirPath, "..", "tmp_xIdP3_builtin_keycloak_theme"); await downloadBuiltinKeycloakTheme({ + projectDirPath, keycloakVersion, - "destDirPath": builtinKeycloakThemeTmpDirPath, - isSilent + "destDirPath": builtinKeycloakThemeTmpDirPath }); transformCodebase({ diff --git a/src/bin/keycloakify/BuildOptions.ts b/src/bin/keycloakify/BuildOptions.ts index b32f9501..b48927b6 100644 --- a/src/bin/keycloakify/BuildOptions.ts +++ b/src/bin/keycloakify/BuildOptions.ts @@ -4,228 +4,135 @@ import { parse as urlParse } from "url"; import { typeGuard } from "tsafe/typeGuard"; import { symToStr } from "tsafe/symToStr"; import { bundlers, getParsedPackageJson, type Bundler } from "./parsedPackageJson"; -import * as fs from "fs"; import { join as pathJoin, sep as pathSep } from "path"; import parseArgv from "minimist"; /** 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 = { - isSilent: boolean; - themeVersion: string; - themeName: string; - extraThemeNames: string[]; - extraThemeProperties: string[] | undefined; - groupId: string; - artifactId: string; - bundler: Bundler; - keycloakVersionDefaultAssets: string; - /** Directory of your built react project. Defaults to {cwd}/build */ - reactAppBuildDirPath: string; - /** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */ - keycloakifyBuildDirPath: 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 & { - areAppAndKeycloakServerSharingSameDomain: true; - }; - - export type DifferentDomains = CommonExternalAssets & { - areAppAndKeycloakServerSharingSameDomain: false; - urlOrigin: string; - urlPathname: string | undefined; - }; - } -} +export type BuildOptions = { + isSilent: boolean; + themeVersion: string; + themeName: string; + extraThemeNames: string[]; + extraThemeProperties: string[] | undefined; + groupId: string; + artifactId: string; + bundler: Bundler; + keycloakVersionDefaultAssets: string; + /** Directory of your built react project. Defaults to {cwd}/build */ + reactAppBuildDirPath: string; + /** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */ + keycloakifyBuildDirPath: string; + /** If your app is hosted under a subpath, it's the case in CRA if you have "homepage": "https://example.com/my-app" in your package.json + * In this case the urlPathname will be "/my-app/" */ + urlPathname: string | undefined; +}; export function readBuildOptions(params: { projectDirPath: string; processArgv: string[] }): BuildOptions { const { projectDirPath, processArgv } = params; - const { isExternalAssetsCliParamProvided, isSilentCliParamProvided } = (() => { + const { isSilentCliParamProvided } = (() => { const argv = parseArgv(processArgv); return { - "isSilentCliParamProvided": typeof argv["silent"] === "boolean" ? argv["silent"] : false, - "isExternalAssetsCliParamProvided": typeof argv["external-assets"] === "boolean" ? argv["external-assets"] : false + "isSilentCliParamProvided": typeof argv["silent"] === "boolean" ? argv["silent"] : false }; })(); const parsedPackageJson = getParsedPackageJson({ projectDirPath }); - const url = (() => { - const { homepage } = parsedPackageJson; + const { name, keycloakify = {}, version, homepage } = parsedPackageJson; - let url: URL | undefined = undefined; + const { extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets, extraThemeNames = [] } = keycloakify ?? {}; - if (homepage !== undefined) { - url = new URL(homepage); - } + const themeName = + keycloakify.themeName ?? + name + .replace(/^@(.*)/, "$1") + .split("/") + .join("-"); - const CNAME = (() => { - const cnameFilePath = pathJoin(projectDirPath, "public", "CNAME"); + return { + themeName, + extraThemeNames, + "bundler": (() => { + const { KEYCLOAKIFY_BUNDLER } = process.env; - if (!fs.existsSync(cnameFilePath)) { + assert( + typeGuard(KEYCLOAKIFY_BUNDLER, [undefined, ...id(bundlers)].includes(KEYCLOAKIFY_BUNDLER)), + `${symToStr({ KEYCLOAKIFY_BUNDLER })} should be one of ${bundlers.join(", ")}` + ); + + return KEYCLOAKIFY_BUNDLER ?? bundler ?? "keycloakify"; + })(), + "artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? artifactId ?? `${themeName}-keycloak-theme`, + "groupId": (() => { + const fallbackGroupId = `${themeName}.keycloak`; + + return ( + process.env.KEYCLOAKIFY_GROUP_ID ?? + groupId ?? + (!homepage + ? fallbackGroupId + : urlParse(homepage) + .host?.replace(/:[0-9]+$/, "") + ?.split(".") + .reverse() + .join(".") ?? fallbackGroupId) + ".keycloak" + ); + })(), + "themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? process.env.KEYCLOAKIFY_VERSION ?? version ?? "0.0.0", + extraThemeProperties, + "isSilent": isSilentCliParamProvided, + "keycloakVersionDefaultAssets": keycloakVersionDefaultAssets ?? "11.0.3", + "reactAppBuildDirPath": (() => { + let { reactAppBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {}; + + if (reactAppBuildDirPath === undefined) { + return pathJoin(projectDirPath, "build"); + } + + if (pathSep === "\\") { + reactAppBuildDirPath = reactAppBuildDirPath.replace(/\//g, pathSep); + } + + if (reactAppBuildDirPath.startsWith(`.${pathSep}`)) { + return pathJoin(projectDirPath, reactAppBuildDirPath); + } + + return reactAppBuildDirPath; + })(), + "keycloakifyBuildDirPath": (() => { + let { keycloakifyBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {}; + + if (keycloakifyBuildDirPath === undefined) { + return pathJoin(projectDirPath, "build_keycloak"); + } + + if (pathSep === "\\") { + keycloakifyBuildDirPath = keycloakifyBuildDirPath.replace(/\//g, pathSep); + } + + if (keycloakifyBuildDirPath.startsWith(`.${pathSep}`)) { + return pathJoin(projectDirPath, keycloakifyBuildDirPath); + } + + return keycloakifyBuildDirPath; + })(), + "urlPathname": (() => { + const { homepage } = parsedPackageJson; + + let url: URL | undefined = undefined; + + if (homepage !== undefined) { + url = new URL(homepage); + } + + if (url === undefined) { return undefined; } - return fs.readFileSync(cnameFilePath).toString("utf8"); - })(); - - 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 { extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets, extraThemeNames = [] } = keycloakify ?? {}; - - const themeName = - keycloakify.themeName ?? - name - .replace(/^@(.*)/, "$1") - .split("/") - .join("-"); - - return { - themeName, - extraThemeNames, - "bundler": (() => { - const { KEYCLOAKIFY_BUNDLER } = process.env; - - assert( - typeGuard( - KEYCLOAKIFY_BUNDLER, - [undefined, ...id(bundlers)].includes(KEYCLOAKIFY_BUNDLER) - ), - `${symToStr({ KEYCLOAKIFY_BUNDLER })} should be one of ${bundlers.join(", ")}` - ); - - return KEYCLOAKIFY_BUNDLER ?? bundler ?? "keycloakify"; - })(), - "artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? artifactId ?? `${themeName}-keycloak-theme`, - "groupId": (() => { - const fallbackGroupId = `${themeName}.keycloak`; - - return ( - process.env.KEYCLOAKIFY_GROUP_ID ?? - groupId ?? - (!homepage - ? fallbackGroupId - : urlParse(homepage) - .host?.replace(/:[0-9]+$/, "") - ?.split(".") - .reverse() - .join(".") ?? fallbackGroupId) + ".keycloak" - ); - })(), - "themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? process.env.KEYCLOAKIFY_VERSION ?? version ?? "0.0.0", - extraThemeProperties, - "isSilent": isSilentCliParamProvided, - "keycloakVersionDefaultAssets": keycloakVersionDefaultAssets ?? "11.0.3", - "reactAppBuildDirPath": (() => { - let { reactAppBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {}; - - if (reactAppBuildDirPath === undefined) { - return pathJoin(projectDirPath, "build"); - } - - if (pathSep === "\\") { - reactAppBuildDirPath = reactAppBuildDirPath.replace(/\//g, pathSep); - } - - if (reactAppBuildDirPath.startsWith(`.${pathSep}`)) { - return pathJoin(projectDirPath, reactAppBuildDirPath); - } - - return reactAppBuildDirPath; - })(), - "keycloakifyBuildDirPath": (() => { - let { keycloakifyBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {}; - - if (keycloakifyBuildDirPath === undefined) { - return pathJoin(projectDirPath, "build_keycloak"); - } - - if (pathSep === "\\") { - keycloakifyBuildDirPath = keycloakifyBuildDirPath.replace(/\//g, pathSep); - } - - if (keycloakifyBuildDirPath.startsWith(`.${pathSep}`)) { - return pathJoin(projectDirPath, keycloakifyBuildDirPath); - } - - return keycloakifyBuildDirPath; - })() - }; - })(); - - if (isExternalAssetsCliParamProvided) { - const commonExternalAssets = id({ - ...common, - "isStandalone": false - }); - - if (parsedPackageJson.keycloakify?.areAppAndKeycloakServerSharingSameDomain) { - return id({ - ...commonExternalAssets, - "areAppAndKeycloakServerSharingSameDomain": 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": { "areAppAndKeycloakServerSharingSameDomain": true }', - "in your package.json" - ].join(" ") - ); - - return id({ - ...commonExternalAssets, - "areAppAndKeycloakServerSharingSameDomain": false, - "urlOrigin": url.origin, - "urlPathname": url.pathname - }); - } - } - - return id({ - ...common, - "isStandalone": true, - "urlPathname": url?.pathname - }); + const out = url.pathname.replace(/([^/])$/, "$1/"); + return out === "/" ? undefined : out; + })() + }; } diff --git a/src/bin/keycloakify/generateFtl/generateFtl.ts b/src/bin/keycloakify/generateFtl/generateFtl.ts index 81444562..fd9e704e 100644 --- a/src/bin/keycloakify/generateFtl/generateFtl.ts +++ b/src/bin/keycloakify/generateFtl/generateFtl.ts @@ -13,39 +13,11 @@ export const themeTypes = ["login", "account"] as const; export type ThemeType = (typeof themeTypes)[number]; -export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets; - -export namespace BuildOptionsLike { - export type Common = { - themeName: string; - themeVersion: string; - }; - - export type Standalone = Common & { - isStandalone: true; - urlPathname: string | undefined; - }; - - export type ExternalAssets = ExternalAssets.SameDomain | ExternalAssets.DifferentDomains; - - export namespace ExternalAssets { - export type CommonExternalAssets = { - isStandalone: false; - }; - - export type SameDomain = Common & - CommonExternalAssets & { - areAppAndKeycloakServerSharingSameDomain: true; - }; - - export type DifferentDomains = Common & - CommonExternalAssets & { - areAppAndKeycloakServerSharingSameDomain: false; - urlOrigin: string; - urlPathname: string | undefined; - }; - } -} +export type BuildOptionsLike = { + themeName: string; + themeVersion: string; + urlPathname: string | undefined; +}; assert(); @@ -63,22 +35,23 @@ export function generateFtlFilesCodeFactory(params: { const $ = cheerio.load(indexHtmlCode); fix_imports_statements: { - if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) { - break fix_imports_statements; - } - $("script:not([src])").each((...[, element]) => { - const { fixedJsCode } = replaceImportsFromStaticInJsCode({ - "jsCode": $(element).html()!, - buildOptions - }); + const jsCode = $(element).html(); + + assert(jsCode !== null); + + const { fixedJsCode } = replaceImportsFromStaticInJsCode({ jsCode }); $(element).text(fixedJsCode); }); $("style").each((...[, element]) => { + const cssCode = $(element).html(); + + assert(cssCode !== null); + const { fixedCssCode } = replaceImportsInInlineCssCode({ - "cssCode": $(element).html()!, + cssCode, buildOptions }); @@ -100,9 +73,7 @@ export function generateFtlFilesCodeFactory(params: { $(element).attr( attrName, - buildOptions.isStandalone - ? href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/") - : href.replace(/^\//, `${buildOptions.urlOrigin}/`) + href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/") ); }) ); diff --git a/src/bin/keycloakify/generateJavaStackFiles.ts b/src/bin/keycloakify/generateJavaStackFiles.ts index 5808d0a1..5fa9266f 100644 --- a/src/bin/keycloakify/generateJavaStackFiles.ts +++ b/src/bin/keycloakify/generateJavaStackFiles.ts @@ -1,7 +1,6 @@ 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"; import type { ThemeType } from "./generateFtl"; @@ -13,11 +12,7 @@ export type BuildOptionsLike = { themeVersion: string; }; -{ - const buildOptions = Reflect(); - - assert(); -} +assert(); export function generateJavaStackFiles(params: { keycloakThemeBuildingDirPath: string; diff --git a/src/bin/keycloakify/generateStartKeycloakTestingContainer.ts b/src/bin/keycloakify/generateStartKeycloakTestingContainer.ts index 76622466..a5efffef 100644 --- a/src/bin/keycloakify/generateStartKeycloakTestingContainer.ts +++ b/src/bin/keycloakify/generateStartKeycloakTestingContainer.ts @@ -1,7 +1,6 @@ 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 = { @@ -9,11 +8,7 @@ export type BuildOptionsLike = { extraThemeNames: string[]; }; -{ - const buildOptions = Reflect(); - - assert(); -} +assert(); generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh"; diff --git a/src/bin/keycloakify/generateTheme/downloadKeycloakStaticResources.ts b/src/bin/keycloakify/generateTheme/downloadKeycloakStaticResources.ts index df23fd58..04167cbb 100644 --- a/src/bin/keycloakify/generateTheme/downloadKeycloakStaticResources.ts +++ b/src/bin/keycloakify/generateTheme/downloadKeycloakStaticResources.ts @@ -13,13 +13,23 @@ import * as crypto from "crypto"; export async function downloadKeycloakStaticResources( // prettier-ignore params: { + projectDirPath: string; themeType: ThemeType; themeDirPath: string; - isSilent: boolean; keycloakVersion: string; + usedResources: { + resourcesCommonFilePaths: string[]; + resourcesFilePaths: string[]; + } | undefined } ) { - const { themeType, isSilent, themeDirPath, keycloakVersion } = params; + const { projectDirPath, themeType, themeDirPath, keycloakVersion, usedResources } = params; + + console.log({ + themeDirPath, + keycloakVersion, + usedResources + }); const tmpDirPath = pathJoin( themeDirPath, @@ -28,19 +38,39 @@ export async function downloadKeycloakStaticResources( ); await downloadBuiltinKeycloakTheme({ + projectDirPath, keycloakVersion, - "destDirPath": tmpDirPath, - isSilent + "destDirPath": tmpDirPath }); transformCodebase({ "srcDirPath": pathJoin(tmpDirPath, "keycloak", themeType, "resources"), - "destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesDirPathRelativeToPublicDir)) + "destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesDirPathRelativeToPublicDir)), + "transformSourceCode": + usedResources === undefined + ? undefined + : ({ fileRelativePath, sourceCode }) => { + if (!usedResources.resourcesFilePaths.includes(fileRelativePath)) { + return undefined; + } + + return { "modifiedSourceCode": sourceCode }; + } }); transformCodebase({ "srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"), - "destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesCommonDirPathRelativeToPublicDir)) + "destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesCommonDirPathRelativeToPublicDir)), + "transformSourceCode": + usedResources === undefined + ? undefined + : ({ fileRelativePath, sourceCode }) => { + if (!usedResources.resourcesCommonFilePaths.includes(fileRelativePath)) { + return undefined; + } + + return { "modifiedSourceCode": sourceCode }; + } }); fs.rmSync(tmpDirPath, { "recursive": true, "force": true }); diff --git a/src/bin/keycloakify/generateTheme/generateTheme.ts b/src/bin/keycloakify/generateTheme/generateTheme.ts index 07a5cc74..c47bc22b 100644 --- a/src/bin/keycloakify/generateTheme/generateTheme.ts +++ b/src/bin/keycloakify/generateTheme/generateTheme.ts @@ -12,45 +12,20 @@ import { downloadKeycloakStaticResources } from "./downloadKeycloakStaticResourc import { readFieldNameUsage } from "./readFieldNameUsage"; import { readExtraPagesNames } from "./readExtraPageNames"; import { generateMessageProperties } from "./generateMessageProperties"; +import { readStaticResourcesUsage } from "./readStaticResourcesUsage"; -export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets; - -export namespace BuildOptionsLike { - export type Common = { - themeName: string; - extraThemeProperties: string[] | undefined; - isSilent: boolean; - themeVersion: string; - keycloakVersionDefaultAssets: 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 & { - areAppAndKeycloakServerSharingSameDomain: true; - }; - - export type DifferentDomains = CommonExternalAssets & { - areAppAndKeycloakServerSharingSameDomain: false; - urlOrigin: string; - urlPathname: string | undefined; - }; - } -} +export type BuildOptionsLike = { + themeName: string; + extraThemeProperties: string[] | undefined; + themeVersion: string; + keycloakVersionDefaultAssets: string; + urlPathname: string | undefined; +}; assert(); export async function generateTheme(params: { + projectDirPath: string; reactAppBuildDirPath: string; keycloakThemeBuildingDirPath: string; themeSrcDirPath: string; @@ -58,7 +33,15 @@ export async function generateTheme(params: { buildOptions: BuildOptionsLike; keycloakifyVersion: string; }): Promise { - const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, themeSrcDirPath, keycloakifySrcDirPath, buildOptions, keycloakifyVersion } = params; + const { + projectDirPath, + reactAppBuildDirPath, + keycloakThemeBuildingDirPath, + themeSrcDirPath, + keycloakifySrcDirPath, + buildOptions, + keycloakifyVersion + } = params; const getThemeDirPath = (themeType: ThemeType | "email") => pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType); @@ -77,17 +60,16 @@ export async function generateTheme(params: { copy_app_resources_to_theme_path: { const isFirstPass = themeType.indexOf(themeType) === 0; - if (!isFirstPass && !buildOptions.isStandalone) { + if (!isFirstPass) { break copy_app_resources_to_theme_path; } transformCodebase({ - "destDirPath": buildOptions.isStandalone ? pathJoin(themeDirPath, "resources", "build") : reactAppBuildDirPath, + "destDirPath": pathJoin(themeDirPath, "resources", "build"), "srcDirPath": reactAppBuildDirPath, "transformSourceCode": ({ filePath, sourceCode }) => { //NOTE: Prevent cycles, excludes the folder we generated for debug in public/ if ( - buildOptions.isStandalone && isInside({ "dirPath": pathJoin(reactAppBuildDirPath, basenameOfKeycloakDirInPublicDir), filePath @@ -97,10 +79,6 @@ export async function generateTheme(params: { } if (/\.css?$/i.test(filePath)) { - if (!buildOptions.isStandalone) { - return undefined; - } - const { cssGlobalsToDefine, fixedCssCode } = replaceImportsInCssCode({ "cssCode": sourceCode.toString("utf8") }); @@ -120,19 +98,14 @@ export async function generateTheme(params: { } if (/\.js?$/i.test(filePath)) { - if (!buildOptions.isStandalone && buildOptions.areAppAndKeycloakServerSharingSameDomain) { - return undefined; - } - const { fixedJsCode } = replaceImportsFromStaticInJsCode({ - "jsCode": sourceCode.toString("utf8"), - buildOptions + "jsCode": sourceCode.toString("utf8") }); return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") }; } - return buildOptions.isStandalone ? { "modifiedSourceCode": sourceCode } : undefined; + return { "modifiedSourceCode": sourceCode }; } }); } @@ -197,10 +170,11 @@ export async function generateTheme(params: { } await downloadKeycloakStaticResources({ - "isSilent": buildOptions.isSilent, + projectDirPath, "keycloakVersion": buildOptions.keycloakVersionDefaultAssets, "themeDirPath": keycloakDirInPublicDir, - themeType + themeType, + "usedResources": undefined }); if (themeType !== themeTypes[0]) { @@ -222,10 +196,15 @@ export async function generateTheme(params: { } await downloadKeycloakStaticResources({ - "isSilent": buildOptions.isSilent, + projectDirPath, "keycloakVersion": buildOptions.keycloakVersionDefaultAssets, themeDirPath, - themeType + themeType, + "usedResources": readStaticResourcesUsage({ + keycloakifySrcDirPath, + themeSrcDirPath, + themeType + }) }); fs.writeFileSync( diff --git a/src/bin/keycloakify/generateTheme/readFieldNameUsage.ts b/src/bin/keycloakify/generateTheme/readFieldNameUsage.ts index cf0cbae9..77aa15da 100644 --- a/src/bin/keycloakify/generateTheme/readFieldNameUsage.ts +++ b/src/bin/keycloakify/generateTheme/readFieldNameUsage.ts @@ -3,7 +3,6 @@ import { removeDuplicates } from "evt/tools/reducers/removeDuplicates"; import { join as pathJoin } from "path"; import * as fs from "fs"; import type { ThemeType } from "../generateFtl"; -import { exclude } from "tsafe/exclude"; /** Assumes the theme type exists */ export function readFieldNameUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): string[] { @@ -11,9 +10,7 @@ export function readFieldNameUsage(params: { keycloakifySrcDirPath: string; them const fieldNames: string[] = []; - for (const srcDirPath of ([pathJoin(keycloakifySrcDirPath, themeType), pathJoin(themeSrcDirPath, themeType)] as const).filter( - exclude(undefined) - )) { + for (const srcDirPath of [pathJoin(keycloakifySrcDirPath, themeType), pathJoin(themeSrcDirPath, themeType)]) { const filePaths = crawl({ "dirPath": srcDirPath, "returnedPathsType": "absolute" }).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath)); for (const filePath of filePaths) { diff --git a/src/bin/keycloakify/generateTheme/readStaticResourcesUsage.ts b/src/bin/keycloakify/generateTheme/readStaticResourcesUsage.ts new file mode 100644 index 00000000..11805f3c --- /dev/null +++ b/src/bin/keycloakify/generateTheme/readStaticResourcesUsage.ts @@ -0,0 +1,85 @@ +import { crawl } from "../../tools/crawl"; +import { join as pathJoin } from "path"; +import * as fs from "fs"; +import type { ThemeType } from "../generateFtl"; + +/** Assumes the theme type exists */ +export function readStaticResourcesUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): { + resourcesCommonFilePaths: string[]; + resourcesFilePaths: string[]; +} { + const { keycloakifySrcDirPath, themeSrcDirPath, themeType } = params; + + const resourcesCommonFilePaths = new Set(); + const resourcesFilePaths = new Set(); + + for (const srcDirPath of [pathJoin(keycloakifySrcDirPath, themeType), pathJoin(themeSrcDirPath, themeType)]) { + const filePaths = crawl({ "dirPath": srcDirPath, "returnedPathsType": "absolute" }).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath)); + + for (const filePath of filePaths) { + const rawSourceFile = fs.readFileSync(filePath).toString("utf8"); + + if (!rawSourceFile.includes("resourcesCommonPath") && !rawSourceFile.includes("resourcesPath")) { + continue; + } + + console.log("=========>", filePath); + + const wrap = readPaths({ rawSourceFile }); + + wrap.resourcesCommonFilePaths.forEach(filePath => resourcesCommonFilePaths.add(filePath)); + wrap.resourcesFilePaths.forEach(filePath => resourcesFilePaths.add(filePath)); + } + } + + return { + "resourcesCommonFilePaths": Array.from(resourcesCommonFilePaths), + "resourcesFilePaths": Array.from(resourcesFilePaths) + }; +} + +/** Exported for testing purpose */ +export function readPaths(params: { rawSourceFile: string }): { + resourcesCommonFilePaths: string[]; + resourcesFilePaths: string[]; +} { + const { rawSourceFile } = params; + + const resourcesCommonFilePaths = new Set(); + const resourcesFilePaths = new Set(); + + for (const isCommon of [true, false]) { + const set = isCommon ? resourcesCommonFilePaths : resourcesFilePaths; + + { + const regexp = new RegExp(`resources${isCommon ? "Common" : ""}Path\\s*}([^\`]+)\``, "g"); + + const matches = [...rawSourceFile.matchAll(regexp)]; + + for (const match of matches) { + const filePath = match[1]; + + set.add(filePath); + } + } + + { + const regexp = new RegExp(`resources${isCommon ? "Common" : ""}Path\\s*[+,]\\s*["']([^"'\`]+)["'\`]`, "g"); + + const matches = [...rawSourceFile.matchAll(regexp)]; + + for (const match of matches) { + const filePath = match[1]; + + set.add(filePath); + } + } + } + + const removePrefixSlash = (filePath: string) => (filePath.startsWith("/") ? filePath.slice(1) : filePath); + + return { + "resourcesCommonFilePaths": Array.from(resourcesCommonFilePaths).map(removePrefixSlash), + "resourcesFilePaths": Array.from(resourcesFilePaths).map(removePrefixSlash) + }; +} diff --git a/src/bin/keycloakify/keycloakify.ts b/src/bin/keycloakify/keycloakify.ts index 4be68f12..0ee0aba4 100644 --- a/src/bin/keycloakify/keycloakify.ts +++ b/src/bin/keycloakify/keycloakify.ts @@ -30,6 +30,7 @@ export async function main() { for (const themeName of [buildOptions.themeName, ...buildOptions.extraThemeNames]) { await generateTheme({ + projectDirPath, "keycloakThemeBuildingDirPath": buildOptions.keycloakifyBuildDirPath, themeSrcDirPath, "keycloakifySrcDirPath": pathJoin(keycloakifyDirPath, "src"), diff --git a/src/bin/keycloakify/replacers/replaceImportsFromStaticInJsCode.ts b/src/bin/keycloakify/replacers/replaceImportsFromStaticInJsCode.ts index ac0ffceb..4ea29ffb 100644 --- a/src/bin/keycloakify/replacers/replaceImportsFromStaticInJsCode.ts +++ b/src/bin/keycloakify/replacers/replaceImportsFromStaticInJsCode.ts @@ -1,31 +1,6 @@ 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 } { +export function replaceImportsFromStaticInJsCode(params: { jsCode: string }): { fixedJsCode: string } { /* NOTE: @@ -38,7 +13,7 @@ export function replaceImportsFromStaticInJsCode(params: { jsCode: string; build will always run in keycloak context. */ - const { jsCode, buildOptions } = params; + const { jsCode } = params; const getReplaceArgs = (language: "js" | "css"): Parameters => [ new RegExp(`([a-zA-Z_]+)\\.([a-zA-Z]+)=function\\(([a-zA-Z]+)\\){return"static\\/${language}\\/"`, "g"), @@ -46,40 +21,23 @@ export function replaceImportsFromStaticInJsCode(params: { jsCode: string; build ${n}[(function(){ var pd= Object.getOwnPropertyDescriptor(${n}, "p"); if( pd === undefined || pd.configurable ){ - ${ - 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;} + get: function() { return window.${ftlValuesGlobalName}.url.resourcesPath; }, + set: function (){} }); - ` - } } return "${u}"; - })()] = function(${e}) { return "${buildOptions.isStandalone ? "/build/" : ""}static/${language}/"` + })()] = function(${e}) { return "${true ? "/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/` - ) + .replace(/[a-zA-Z]+\.[a-zA-Z]+\+"static\//g, `window.${ftlValuesGlobalName}.url.resourcesPath + "/build/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},` + .replace( + /".chunk.css",([a-zA-Z])+=[a-zA-Z]+\.[a-zA-Z]+\+([a-zA-Z]+),/, + (...[, group1, group2]) => `".chunk.css",${group1} = window.${ftlValuesGlobalName}.url.resourcesPath + "/build/" + ${group2},` ); return { fixedJsCode }; diff --git a/src/bin/keycloakify/replacers/replaceImportsInCssCode.ts b/src/bin/keycloakify/replacers/replaceImportsInCssCode.ts index 278986ba..9212b7b0 100644 --- a/src/bin/keycloakify/replacers/replaceImportsInCssCode.ts +++ b/src/bin/keycloakify/replacers/replaceImportsInCssCode.ts @@ -1,20 +1,12 @@ 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(); -} +assert(); export function replaceImportsInCssCode(params: { cssCode: string }): { fixedCssCode: string; diff --git a/src/bin/keycloakify/replacers/replaceImportsInInlineCssCode.ts b/src/bin/keycloakify/replacers/replaceImportsInInlineCssCode.ts index c7551621..88b3e0e8 100644 --- a/src/bin/keycloakify/replacers/replaceImportsInInlineCssCode.ts +++ b/src/bin/keycloakify/replacers/replaceImportsInInlineCssCode.ts @@ -1,32 +1,11 @@ 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 type BuildOptionsLike = { + urlPathname: string | undefined; +}; -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(); -} +assert(); export function replaceImportsInInlineCssCode(params: { cssCode: string; buildOptions: BuildOptionsLike }): { fixedCssCode: string; @@ -37,10 +16,7 @@ export function replaceImportsInInlineCssCode(params: { cssCode: string; buildOp buildOptions.urlPathname === undefined ? /url\(["']?\/([^/][^)"']+)["']?\)/g : new RegExp(`url\\(["']?${buildOptions.urlPathname}([^)"']+)["']?\\)`, "g"), - (...[, group]) => - `url(${ - buildOptions.isStandalone ? "${url.resourcesPath}/build/" + group : buildOptions.urlOrigin + (buildOptions.urlPathname ?? "/") + group - })` + (...[, group]) => `url(\${url.resourcesPath}/build/${group})` ); return { fixedCssCode }; diff --git a/src/bin/tools/downloadAndUnzip.ts b/src/bin/tools/downloadAndUnzip.ts index cd6fb18a..5d7dcc0c 100644 --- a/src/bin/tools/downloadAndUnzip.ts +++ b/src/bin/tools/downloadAndUnzip.ts @@ -1,18 +1,55 @@ import { exec as execCallback } from "child_process"; import { createHash } from "crypto"; -import { mkdir, readFile, stat, writeFile } from "fs/promises"; +import { mkdir, readFile, stat, writeFile, unlink, rm } from "fs/promises"; import fetch, { type FetchOptions } from "make-fetch-happen"; import { dirname as pathDirname, join as pathJoin, resolve as pathResolve, sep as pathSep } from "path"; import { assert } from "tsafe/assert"; import { promisify } from "util"; -import { getProjectRoot } from "./getProjectRoot"; import { transformCodebase } from "./transformCodebase"; -import { unzip } from "./unzip"; +import { unzip, zip } from "./unzip"; const exec = promisify(execCallback); -function hash(s: string) { - return createHash("sha256").update(s).digest("hex"); +function generateFileNameFromURL(params: { + url: string; + preCacheTransform: + | { + actionCacheId: string; + actionFootprint: string; + } + | undefined; +}): string { + const { preCacheTransform } = params; + + // Parse the URL + const url = new URL(params.url); + + // Extract pathname and remove leading slashes + let fileName = url.pathname.replace(/^\//, "").replace(/\//g, "_"); + + // Optionally, add query parameters replacing special characters + if (url.search) { + fileName += url.search.replace(/[&=?]/g, "-"); + } + + // Replace any characters that are not valid in filenames + fileName = fileName.replace(/[^a-zA-Z0-9-_]/g, ""); + + // Trim or pad the fileName to a specific length + fileName = fileName.substring(0, 50); + + add_pre_cache_transform: { + if (preCacheTransform === undefined) { + break add_pre_cache_transform; + } + + // Sanitize actionCacheId the same way as other components + const sanitizedActionCacheId = preCacheTransform.actionCacheId.replace(/[^a-zA-Z0-9-_]/g, "_"); + + fileName += `_${sanitizedActionCacheId}_${createHash("sha256").update(preCacheTransform.actionFootprint).digest("hex").substring(0, 5)}`; + } + + return fileName; } async function exists(path: string) { @@ -113,14 +150,43 @@ async function getFetchOptions(): Promise Promise; + }; + } & ( + | { + doUseCache: true; + projectDirPath: string; + } + | { + doUseCache: false; + } + ) +) { + const { url, destDirPath, specificDirsToExtract, preCacheTransform, ...rest } = params; - const downloadHash = hash(JSON.stringify({ url })).substring(0, 15); - const projectRoot = getProjectRoot(); - const cacheRoot = process.env.XDG_CACHE_HOME ?? pathJoin(projectRoot, "node_modules", ".cache"); - const zipFilePath = pathJoin(cacheRoot, "keycloakify", "zip", `_${downloadHash}.zip`); - const extractDirPath = pathJoin(cacheRoot, "keycloakify", "unzip", `_${downloadHash}`); + const zipFileBasename = generateFileNameFromURL({ + url, + "preCacheTransform": + preCacheTransform === undefined + ? undefined + : { + "actionCacheId": preCacheTransform.actionCacheId, + "actionFootprint": preCacheTransform.action.toString() + } + }); + + const cacheRoot = !rest.doUseCache + ? `tmp_${Math.random().toString().slice(2, 12)}` + : pathJoin(process.env.XDG_CACHE_HOME ?? pathJoin(rest.projectDirPath, "node_modules", ".cache"), "keycloakify"); + const zipFilePath = pathJoin(cacheRoot, `${zipFileBasename}.zip`); + const extractDirPath = pathJoin(cacheRoot, `tmp_unzip_${zipFileBasename}`); if (!(await exists(zipFilePath))) { const opts = await getFetchOptions(); @@ -136,12 +202,32 @@ export async function downloadAndUnzip(params: { url: string; destDirPath: strin response.body?.setMaxListeners(Number.MAX_VALUE); assert(typeof response.body !== "undefined" && response.body != null); await writeFile(zipFilePath, response.body); + + if (specificDirsToExtract !== undefined || preCacheTransform !== undefined) { + await unzip(zipFilePath, extractDirPath, specificDirsToExtract); + + await preCacheTransform?.action({ + "destDirPath": extractDirPath + }); + + await unlink(zipFilePath); + + await zip(extractDirPath, zipFilePath); + + await rm(extractDirPath, { "recursive": true }); + } } - await unzip(zipFilePath, extractDirPath, pathOfDirToExtractInArchive); + await unzip(zipFilePath, extractDirPath); transformCodebase({ "srcDirPath": extractDirPath, "destDirPath": destDirPath }); + + if (!rest.doUseCache) { + await rm(cacheRoot, { "recursive": true }); + } else { + await rm(extractDirPath, { "recursive": true }); + } } diff --git a/src/bin/tools/transformCodebase.ts b/src/bin/tools/transformCodebase.ts index 9e04074a..2064fe7d 100644 --- a/src/bin/tools/transformCodebase.ts +++ b/src/bin/tools/transformCodebase.ts @@ -3,7 +3,7 @@ import * as path from "path"; import { crawl } from "./crawl"; import { id } from "tsafe/id"; -type TransformSourceCode = (params: { sourceCode: Buffer; filePath: string }) => +type TransformSourceCode = (params: { sourceCode: Buffer; filePath: string; fileRelativePath: string }) => | { modifiedSourceCode: Buffer; newFileName?: string; @@ -20,26 +20,27 @@ export function transformCodebase(params: { srcDirPath: string; destDirPath: str })) } = params; - for (const file_relative_path of crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })) { - const filePath = path.join(srcDirPath, file_relative_path); + for (const fileRelativePath of crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })) { + const filePath = path.join(srcDirPath, fileRelativePath); const transformSourceCodeResult = transformSourceCode({ "sourceCode": fs.readFileSync(filePath), - filePath + filePath, + fileRelativePath }); if (transformSourceCodeResult === undefined) { continue; } - fs.mkdirSync(path.dirname(path.join(destDirPath, file_relative_path)), { + fs.mkdirSync(path.dirname(path.join(destDirPath, fileRelativePath)), { "recursive": true }); const { newFileName, modifiedSourceCode } = transformSourceCodeResult; fs.writeFileSync( - path.join(path.dirname(path.join(destDirPath, file_relative_path)), newFileName ?? path.basename(file_relative_path)), + path.join(path.dirname(path.join(destDirPath, fileRelativePath)), newFileName ?? path.basename(fileRelativePath)), modifiedSourceCode ); } diff --git a/src/bin/tools/unzip.ts b/src/bin/tools/unzip.ts index 3180ad2a..eee80721 100644 --- a/src/bin/tools/unzip.ts +++ b/src/bin/tools/unzip.ts @@ -2,6 +2,7 @@ import fsp from "node:fs/promises"; import fs from "fs"; import path from "node:path"; import yauzl from "yauzl"; +import yazl from "yazl"; import stream from "node:stream"; import { promisify } from "node:util"; @@ -19,11 +20,16 @@ async function pathExists(path: string) { } } -export async function unzip(file: string, targetFolder: string, unzipSubPath?: string) { - // add trailing slash to unzipSubPath and targetFolder - if (unzipSubPath && (!unzipSubPath.endsWith("/") || !unzipSubPath.endsWith("\\"))) { - unzipSubPath += "/"; - } +// Handlings of non posix path is not implemented correctly +// it work by coincidence. Don't have the time to fix but it should be fixed. +export async function unzip(file: string, targetFolder: string, specificDirsToExtract?: string[]) { + specificDirsToExtract = specificDirsToExtract?.map(dirPath => { + if (!dirPath.endsWith("/") || !dirPath.endsWith("\\")) { + dirPath += "/"; + } + + return dirPath; + }); if (!targetFolder.endsWith("/") || !targetFolder.endsWith("\\")) { targetFolder += "/"; @@ -42,15 +48,17 @@ export async function unzip(file: string, targetFolder: string, unzipSubPath?: s zipfile.readEntry(); zipfile.on("entry", async entry => { - if (unzipSubPath) { + if (specificDirsToExtract !== undefined) { + const dirPath = specificDirsToExtract.find(dirPath => entry.fileName.startsWith(dirPath)); + // Skip files outside of the unzipSubPath - if (!entry.fileName.startsWith(unzipSubPath)) { + if (dirPath === undefined) { zipfile.readEntry(); return; } // Remove the unzipSubPath from the file name - entry.fileName = entry.fileName.substring(unzipSubPath.length); + entry.fileName = entry.fileName.substring(dirPath.length); } const target = path.join(targetFolder, entry.fileName); @@ -77,6 +85,8 @@ export async function unzip(file: string, targetFolder: string, unzipSubPath?: s return; } + await fsp.mkdir(path.dirname(target), { "recursive": true }); + await pipeline(readStream, fs.createWriteStream(target)); zipfile.readEntry(); @@ -90,3 +100,42 @@ export async function unzip(file: string, targetFolder: string, unzipSubPath?: s }); }); } + +// NOTE: This code was directly copied from ChatGPT and appears to function as expected. +// However, confidence in its complete accuracy and robustness is limited. +export async function zip(sourceFolder: string, targetZip: string) { + return new Promise(async (resolve, reject) => { + const zipfile = new yazl.ZipFile(); + const files: string[] = []; + + // Recursive function to explore directories and their subdirectories + async function exploreDir(dir: string) { + const dirContent = await fsp.readdir(dir); + for (const file of dirContent) { + const filePath = path.join(dir, file); + const stat = await fsp.stat(filePath); + if (stat.isDirectory()) { + await exploreDir(filePath); + } else if (stat.isFile()) { + files.push(filePath); + } + } + } + + // Collecting all files to be zipped + await exploreDir(sourceFolder); + + // Adding files to zip + for (const file of files) { + const relativePath = path.relative(sourceFolder, file); + zipfile.addFile(file, relativePath); + } + + zipfile.outputStream + .pipe(fs.createWriteStream(targetZip)) + .on("close", () => resolve()) + .on("error", err => reject(err)); // Listen to error events + + zipfile.end(); + }); +} diff --git a/src/lib/usePrepareTemplate.ts b/src/lib/usePrepareTemplate.ts index ee49a93c..c71726ed 100644 --- a/src/lib/usePrepareTemplate.ts +++ b/src/lib/usePrepareTemplate.ts @@ -1,21 +1,15 @@ import { useReducer, useEffect } from "react"; import { headInsert } from "keycloakify/tools/headInsert"; -import { pathJoin } from "keycloakify/bin/tools/pathJoin"; import { clsx } from "keycloakify/tools/clsx"; export function usePrepareTemplate(params: { doFetchDefaultThemeResources: boolean; - stylesCommon?: string[]; styles?: string[]; scripts?: string[]; - url: { - resourcesCommonPath: string; - resourcesPath: string; - }; htmlClassName: string | undefined; bodyClassName: string | undefined; }) { - const { doFetchDefaultThemeResources, stylesCommon = [], styles = [], url, scripts = [], htmlClassName, bodyClassName } = params; + const { doFetchDefaultThemeResources, styles = [], scripts = [], htmlClassName, bodyClassName } = params; const [isReady, setReady] = useReducer(() => true, !doFetchDefaultThemeResources); @@ -31,23 +25,18 @@ export function usePrepareTemplate(params: { (async () => { const prLoadedArray: Promise[] = []; - [ - ...stylesCommon.map(relativePath => pathJoin(url.resourcesCommonPath, relativePath)), - ...styles.map(relativePath => pathJoin(url.resourcesPath, relativePath)) - ] - .reverse() - .forEach(href => { - const { prLoaded, remove } = headInsert({ - "type": "css", - "position": "prepend", - href - }); - - removeArray.push(remove); - - prLoadedArray.push(prLoaded); + styles.reverse().forEach(href => { + const { prLoaded, remove } = headInsert({ + "type": "css", + "position": "prepend", + href }); + removeArray.push(remove); + + prLoadedArray.push(prLoaded); + }); + await Promise.all(prLoadedArray); if (isUnmounted) { @@ -57,10 +46,10 @@ export function usePrepareTemplate(params: { setReady(); })(); - scripts.forEach(relativePath => { + scripts.forEach(src => { const { remove } = headInsert({ "type": "javascript", - "src": pathJoin(url.resourcesPath, relativePath) + src }); removeArray.push(remove); diff --git a/src/login/Template.tsx b/src/login/Template.tsx index 98b0d07d..4c555b5d 100644 --- a/src/login/Template.tsx +++ b/src/login/Template.tsx @@ -31,13 +31,12 @@ export default function Template(props: TemplateProps) { const { isReady } = usePrepareTemplate({ "doFetchDefaultThemeResources": doUseDefaultCss, - url, - "stylesCommon": [ - "node_modules/patternfly/dist/css/patternfly.min.css", - "node_modules/patternfly/dist/css/patternfly-additions.min.css", - "lib/zocial/zocial.css" + "styles": [ + `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`, + `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`, + `${url.resourcesCommonPath}/lib/zocial/zocial.css`, + `${url.resourcesPath}/css/login.css` ], - "styles": ["css/login.css"], "htmlClassName": getClassName("kcHtmlClass"), "bodyClassName": undefined }); diff --git a/src/login/pages/LoginOtp.tsx b/src/login/pages/LoginOtp.tsx index c6126fab..f4e99d34 100644 --- a/src/login/pages/LoginOtp.tsx +++ b/src/login/pages/LoginOtp.tsx @@ -1,6 +1,5 @@ import { useEffect } from "react"; import { headInsert } from "keycloakify/tools/headInsert"; -import { pathJoin } from "keycloakify/bin/tools/pathJoin"; import { clsx } from "keycloakify/tools/clsx"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; @@ -24,7 +23,7 @@ export default function LoginOtp(props: PageProps { diff --git a/test/bin/readStaticResourcesUsage.spec.ts b/test/bin/readStaticResourcesUsage.spec.ts new file mode 100644 index 00000000..74b74db2 --- /dev/null +++ b/test/bin/readStaticResourcesUsage.spec.ts @@ -0,0 +1,108 @@ +import { readPaths } from "keycloakify/bin/keycloakify/generateTheme/readStaticResourcesUsage"; +import { same } from "evt/tools/inDepth/same"; +import { expect, it, describe } from "vitest"; + +describe("Ensure it's able to extract used Keycloak resources", () => { + const expectedPaths = { + "resourcesCommonFilePaths": [ + "node_modules/patternfly/dist/css/patternfly.min.css", + "node_modules/patternfly/dist/css/patternfly-additions.min.css", + "lib/zocial/zocial.css", + "node_modules/jquery/dist/jquery.min.js" + ], + "resourcesFilePaths": ["css/login.css"] + }; + + it("works with coding style n°1", () => { + const paths = readPaths({ + "rawSourceFile": ` + const { isReady } = usePrepareTemplate({ + "doFetchDefaultThemeResources": doUseDefaultCss, + "styles": [ + \`\${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css\`, + \`\${ + url.resourcesCommonPath + }/node_modules/patternfly/dist/css/patternfly-additions.min.css\`, + \`\${resourcesCommonPath }/lib/zocial/zocial.css\`, + \`\${url.resourcesPath}/css/login.css\` + ], + "htmlClassName": getClassName("kcHtmlClass"), + "bodyClassName": undefined + }); + + const { prLoaded, remove } = headInsert({ + "type": "javascript", + "src": \`\${kcContext.url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js\` + }); + + ` + }); + + expect(same(paths, expectedPaths)).toBe(true); + }); + + it("works with coding style n°2", () => { + const paths = readPaths({ + "rawSourceFile": ` + + const { isReady } = usePrepareTemplate({ + "doFetchDefaultThemeResources": doUseDefaultCss, + "styles": [ + url.resourcesCommonPath + "/node_modules/patternfly/dist/css/patternfly.min.css", + url.resourcesCommonPath + '/node_modules/patternfly/dist/css/patternfly-additions.min.css', + url.resourcesCommonPath + + "/lib/zocial/zocial.css", + url.resourcesPath + + '/css/login.css' + ], + "htmlClassName": getClassName("kcHtmlClass"), + "bodyClassName": undefined + }); + + const { prLoaded, remove } = headInsert({ + "type": "javascript", + "src": kcContext.url.resourcesCommonPath + "/node_modules/jquery/dist/jquery.min.js\" + }); + + + ` + }); + + console.log(paths); + console.log(expectedPaths); + + expect(same(paths, expectedPaths)).toBe(true); + }); + + it("works with coding style n°3", () => { + const paths = readPaths({ + "rawSourceFile": ` + + const { isReady } = usePrepareTemplate({ + "doFetchDefaultThemeResources": doUseDefaultCss, + "styles": [ + path.join(resourcesCommonPath,"/node_modules/patternfly/dist/css/patternfly.min.css"), + path.join(url.resourcesCommonPath, '/node_modules/patternfly/dist/css/patternfly-additions.min.css'), + path.join(url.resourcesCommonPath, + "/lib/zocial/zocial.css"), + pathJoin( + url.resourcesPath, + 'css/login.css' + ) + ], + "htmlClassName": getClassName("kcHtmlClass"), + "bodyClassName": undefined + }); + + const { prLoaded, remove } = headInsert({ + "type": "javascript", + "src": path.join(kcContext.url.resourcesCommonPath, "/node_modules/jquery/dist/jquery.min.js") + }); + + + ` + }); + + expect(same(paths, expectedPaths)).toBe(true); + }); +}); diff --git a/test/bin/replaceImportFromStatic.spec.ts b/test/bin/replaceImportFromStatic.spec.ts index c7894aca..269ca255 100644 --- a/test/bin/replaceImportFromStatic.spec.ts +++ b/test/bin/replaceImportFromStatic.spec.ts @@ -35,10 +35,7 @@ describe("bin/js-transforms", () => { `; it("transforms standalone code properly", () => { const { fixedJsCode } = replaceImportsFromStaticInJsCode({ - "jsCode": jsCodeUntransformed, - "buildOptions": { - "isStandalone": true - } + "jsCode": jsCodeUntransformed }); const fixedJsCodeExpected = ` @@ -89,66 +86,6 @@ describe("bin/js-transforms", () => { `; - expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true); - }); - it("transforms external app code properly", () => { - const { fixedJsCode } = replaceImportsFromStaticInJsCode({ - "jsCode": jsCodeUntransformed, - "buildOptions": { - "isStandalone": false, - "urlOrigin": "https://demo-app.keycloakify.dev" - } - }); - - const fixedJsCodeExpected = ` - function f() { - return ("kcContext" in window ? "https://demo-app.keycloakify.dev/" : a.p) + "static/js/" + ({}[e] || e) + "." + { - 3: "0664cdc0" - }[e] + ".chunk.js" - } - - function sameAsF() { - return ("kcContext" in window ? "https://demo-app.keycloakify.dev/" : a.p) + "static/js/" + ({}[e] || e) + "." + { - 3: "0664cdc0" - }[e] + ".chunk.js" - } - - __webpack_require__[(function (){ - var pd= Object.getOwnPropertyDescriptor(__webpack_require__, "p"); - if( pd === undefined || pd.configurable ){ - var p= ""; - Object.defineProperty(__webpack_require__, "p", { - get: function() { return "kcContext" in window ? "https://demo-app.keycloakify.dev/" : p; }, - set: function (value){ p = value; } - }); - } - return "u"; - })()] = function(e) { - return "static/js/" + e + "." + { - 147: "6c5cee76", - 787: "8da10fcf", - 922: "be170a73" - } [e] + ".chunk.js" - } - - t[(function (){ - var pd= Object.getOwnPropertyDescriptor(t, "p"); - if( pd === undefined || pd.configurable ){ - var p= ""; - Object.defineProperty(t, "p", { - get: function() { return "kcContext" in window ? "https://demo-app.keycloakify.dev/" : p; }, - set: function (value){ p = value; } - }); - } - return "miniCssF"; - })()] = function(e) { - return "static/css/" + e + "." + { - 164:"dcfd7749", - 908:"67c9ed2c" - } [e] + ".chunk.css" - } - `; - expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true); }); }); @@ -304,7 +241,6 @@ describe("bin/css-inline-transforms", () => { const { fixedCssCode } = replaceImportsInInlineCssCode({ cssCode, "buildOptions": { - "isStandalone": true, "urlPathname": undefined } }); @@ -344,53 +280,6 @@ describe("bin/css-inline-transforms", () => { } `; - expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true); - }); - it("transforms css for external app properly", () => { - const { fixedCssCode } = replaceImportsInInlineCssCode({ - cssCode, - "buildOptions": { - "isStandalone": false, - "urlOrigin": "https://demo-app.keycloakify.dev", - "urlPathname": undefined - } - }); - - const fixedCssCodeExpected = ` - @font-face { - font-family: "Work Sans"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(https://demo-app.keycloakify.dev/fonts/WorkSans/worksans-regular-webfont.woff2) - format("woff2"); - } - @font-face { - font-family: "Work Sans"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(https://demo-app.keycloakify.dev/fonts/WorkSans/worksans-medium-webfont.woff2) - format("woff2"); - } - @font-face { - font-family: "Work Sans"; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url(https://demo-app.keycloakify.dev/fonts/WorkSans/worksans-semibold-webfont.woff2) - format("woff2"); - } - @font-face { - font-family: "Work Sans"; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url(https://demo-app.keycloakify.dev/fonts/WorkSans/worksans-bold-webfont.woff2) - format("woff2"); - } - `; - expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true); }); }); @@ -430,7 +319,6 @@ describe("bin/css-inline-transforms", () => { const { fixedCssCode } = replaceImportsInInlineCssCode({ cssCode, "buildOptions": { - "isStandalone": true, "urlPathname": "/x/y/z/" } }); @@ -470,53 +358,6 @@ describe("bin/css-inline-transforms", () => { } `; - expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true); - }); - it("transforms css for external app properly", () => { - const { fixedCssCode } = replaceImportsInInlineCssCode({ - cssCode, - "buildOptions": { - "isStandalone": false, - "urlOrigin": "https://demo-app.keycloakify.dev", - "urlPathname": "/x/y/z/" - } - }); - - const fixedCssCodeExpected = ` - @font-face { - font-family: "Work Sans"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-regular-webfont.woff2) - format("woff2"); - } - @font-face { - font-family: "Work Sans"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-medium-webfont.woff2) - format("woff2"); - } - @font-face { - font-family: "Work Sans"; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-semibold-webfont.woff2) - format("woff2"); - } - @font-face { - font-family: "Work Sans"; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url(https://demo-app.keycloakify.dev/x/y/z/fonts/WorkSans/worksans-bold-webfont.woff2) - format("woff2"); - } - `; - expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true); }); }); diff --git a/test/bin/setupSampleReactProject.spec.ts b/test/bin/setupSampleReactProject.spec.ts index 1a2cbb85..1995b49e 100644 --- a/test/bin/setupSampleReactProject.spec.ts +++ b/test/bin/setupSampleReactProject.spec.ts @@ -12,7 +12,8 @@ export const sampleReactProjectDirPath = pathJoin(getProjectRoot(), "sample_reac async function setupSampleReactProject(destDir: string) { await downloadAndUnzip({ "url": "https://github.com/keycloakify/keycloakify/releases/download/v0.0.1/sample_build_dir_and_package_json.zip", - "destDirPath": destDir + "destDirPath": destDir, + "doUseCache": false }); } let parsedPackageJson: Record = {}; @@ -51,17 +52,19 @@ describe("Sample Project", () => { await setupSampleReactProject(sampleReactProjectDirPath); await initializeEmailTheme(); + const projectDirPath = process.cwd(); + const destDirPath = pathJoin( readBuildOptions({ "processArgv": ["--silent"], - "projectDirPath": process.cwd() + projectDirPath }).keycloakifyBuildDirPath, "src", "main", "resources", "theme" ); - await downloadBuiltinKeycloakTheme({ destDirPath, keycloakVersion: "11.0.3", "isSilent": false }); + await downloadBuiltinKeycloakTheme({ destDirPath, "keycloakVersion": "11.0.3", projectDirPath }); }, { timeout: 90000 } ); @@ -77,17 +80,19 @@ describe("Sample Project", () => { await setupSampleReactProject(pathJoin(sampleReactProjectDirPath, "custom_input")); await initializeEmailTheme(); + const projectDirPath = process.cwd(); + const destDirPath = pathJoin( readBuildOptions({ "processArgv": ["--silent"], - "projectDirPath": process.cwd() + projectDirPath }).keycloakifyBuildDirPath, "src", "main", "resources", "theme" ); - await downloadBuiltinKeycloakTheme({ destDirPath, "keycloakVersion": "11.0.3", "isSilent": false }); + await downloadBuiltinKeycloakTheme({ destDirPath, "keycloakVersion": "11.0.3", projectDirPath }); }, { timeout: 90000 } ); diff --git a/test/bin/setupSampleReactProject.ts b/test/bin/setupSampleReactProject.ts index 12061e68..5ea6daf1 100644 --- a/test/bin/setupSampleReactProject.ts +++ b/test/bin/setupSampleReactProject.ts @@ -3,6 +3,7 @@ import { downloadAndUnzip } from "keycloakify/bin/tools/downloadAndUnzip"; export async function setupSampleReactProject(destDirPath: string) { await downloadAndUnzip({ "url": "https://github.com/keycloakify/keycloakify/releases/download/v0.0.1/sample_build_dir_and_package_json.zip", - "destDirPath": destDirPath + "destDirPath": destDirPath, + "doUseCache": false }); }