Compare commits
60 Commits
Author | SHA1 | Date | |
---|---|---|---|
44402c9571 | |||
ffefb38161 | |||
6d667f653e | |||
1c75fed727 | |||
9c133be779 | |||
71eb953fd3 | |||
f49ef21fed | |||
6a6fa04ba0 | |||
83b0838c94 | |||
4ebc1e671f | |||
08c7e38587 | |||
b863d9feb3 | |||
e527f043b0 | |||
58bb403787 | |||
e4725c23eb | |||
b0db8caf65 | |||
3bcc6bdf93 | |||
eafb75a958 | |||
31ca0939aa | |||
7784fdcd6a | |||
8247eef735 | |||
cb6629f301 | |||
3a6fe1b374 | |||
0ba2f37004 | |||
e052dee753 | |||
9c2ec32d12 | |||
1669c38bc9 | |||
c6ce6d1b49 | |||
bc242b0aa7 | |||
41b67f6af4 | |||
bef21e1cb9 | |||
8c73630f5a | |||
724953d5b7 | |||
a22b231982 | |||
910bfe2318 | |||
70a524da46 | |||
bf6c846fac | |||
b83e4bef3f | |||
9f7fe0d8f7 | |||
741dee57e4 | |||
fff4dba708 | |||
f4f7ab3e49 | |||
88fe99b1b8 | |||
92c1486f6a | |||
caea64cef3 | |||
90783d8ee8 | |||
be57801e21 | |||
ff84786b4e | |||
1e863672cb | |||
fb98a9c383 | |||
05163f22cb | |||
160f12d7d3 | |||
49e4e36184 | |||
c4f8879cda | |||
8f54166653 | |||
b9f020c447 | |||
c357f3eb4d | |||
7ebbb0417a | |||
6e4b4173b5 | |||
253825a35e |
11
.gitignore
vendored
11
.gitignore
vendored
@ -41,14 +41,15 @@ jspm_packages
|
||||
.DS_Store
|
||||
|
||||
/dist
|
||||
/dist_test
|
||||
|
||||
/keycloakify_starter_test/
|
||||
/sample_custom_react_project/
|
||||
/sample_react_project/
|
||||
/.yarn_home/
|
||||
|
||||
.idea
|
||||
|
||||
/keycloak_email
|
||||
/build_keycloak
|
||||
/src/login/i18n/baseMessages/
|
||||
/src/account/i18n/baseMessages/
|
||||
/src/account/i18n/baseMessages/
|
||||
|
||||
# VS Code devcontainers
|
||||
.devcontainer
|
@ -1,12 +1,15 @@
|
||||
node_modules/
|
||||
/dist/
|
||||
/dist_test/
|
||||
/CHANGELOG.md
|
||||
/.yarn_home/
|
||||
/src/test/apps/
|
||||
/src/tools/types/
|
||||
/sample_react_project
|
||||
/build_keycloak/
|
||||
/.vscode/
|
||||
/src/login/i18n/baseMessages/
|
||||
/src/account/i18n/baseMessages/
|
||||
# Test Build Directories
|
||||
/dist_test
|
||||
/sample_react_project/
|
||||
/sample_custom_react_project/
|
||||
/keycloakify_starter_test/
|
44
README.md
44
README.md
@ -14,7 +14,7 @@
|
||||
<a href="https://github.com/garronej/keycloakify/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/npm/l/keycloakify">
|
||||
</a>
|
||||
<a href="https://github.com/InseeFrLab/keycloakify/blob/729503fe31a155a823f46dd66ad4ff34ca274e0a/tsconfig.json#L14">
|
||||
<a href="https://github.com/keycloakify/keycloakify/blob/729503fe31a155a823f46dd66ad4ff34ca274e0a/tsconfig.json#L14">
|
||||
<img src="https://camo.githubusercontent.com/0f9fcc0ac1b8617ad4989364f60f78b2d6b32985ad6a508f215f14d8f897b8d3/68747470733a2f2f62616467656e2e6e65742f62616467652f547970655363726970742f7374726963742532302546302539462539322541412f626c7565">
|
||||
</a>
|
||||
<a href="https://github.com/thomasdarimont/awesome-keycloak">
|
||||
@ -25,6 +25,8 @@
|
||||
-
|
||||
<a href="https://docs.keycloakify.dev">Documentation</a>
|
||||
-
|
||||
<a href="https://storybook.keycloakify.dev/storybook">Storybook</a>
|
||||
-
|
||||
<a href="https://github.com/codegouvfr/keycloakify-starter">Starter project</a>
|
||||
</p>
|
||||
</p>
|
||||
@ -34,9 +36,11 @@
|
||||
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
|
||||
</p>
|
||||
|
||||
> 🗣 V6 have been released 🎉
|
||||
> [It features major improvements](https://github.com/InseeFrLab/keycloakify#600).
|
||||
> Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6).
|
||||
The more ⭐️ the project gets, the more time I spend improving and maintaining it. Thank you for your support 😊
|
||||
|
||||
> 🗣 V7 have been released 🎉
|
||||
> [It features major improvements](https://github.com/keycloakify/keycloakify#70-).
|
||||
> Checkout [the migration guide](https://docs.keycloakify.dev/migration-guides/v6-greater-than-v7).
|
||||
|
||||
# Changelog highlights
|
||||
|
||||
@ -45,10 +49,12 @@
|
||||
- Account theme support 🚀
|
||||
- It's much easier to customize pages at the CSS level, you can now see in the browser dev tool the customizable classes.
|
||||
- New interactive CLI tool `npx eject-keycloak-page`, that enables to select the page you want to customize at the component level.
|
||||
- There is [a Storybook](https://storybook.keycloakify.dev)
|
||||
- [Remember me is fixed](https://github.com/keycloakify/keycloakify/pull/272)
|
||||
|
||||
## 6.13
|
||||
|
||||
- Build work behind corporate proxies, [see issue](https://github.com/InseeFrLab/keycloakify/issues/257).
|
||||
- Build work behind corporate proxies, [see issue](https://github.com/keycloakify/keycloakify/issues/257).
|
||||
|
||||
## 6.12
|
||||
|
||||
@ -61,13 +67,13 @@ Massive improvement in the developer experience:
|
||||
|
||||
## 6.11.4
|
||||
|
||||
- You no longer need to have Maven installed to build the theme. Thanks to @lordvlad, [see PR](https://github.com/InseeFrLab/keycloakify/pull/239).
|
||||
- You no longer need to have Maven installed to build the theme. Thanks to @lordvlad, [see PR](https://github.com/keycloakify/keycloakify/pull/239).
|
||||
- Feature new build options: [`bundler`](https://docs.keycloakify.dev/build-options#keycloakify.bundler), [`groupId`](https://docs.keycloakify.dev/build-options#keycloakify.groupid), [`artifactId`](https://docs.keycloakify.dev/build-options#keycloakify.artifactid), [`version`](https://docs.keycloakify.dev/build-options#version).
|
||||
Theses options can be user to customize the output name of the .jar. You can use environnement variables to overrides the values read in the package.json. Thanks to @lordvlad.
|
||||
|
||||
## 6.10.0
|
||||
|
||||
- Widows compat (thanks to @lordvlad, [see PR](https://github.com/InseeFrLab/keycloakify/pull/226)). WSL is no longer required 🎉
|
||||
- Widows compat (thanks to @lordvlad, [see PR](https://github.com/keycloakify/keycloakify/pull/226)). WSL is no longer required 🎉
|
||||
|
||||
## 6.8.4
|
||||
|
||||
@ -77,19 +83,19 @@ Massive improvement in the developer experience:
|
||||
|
||||
- It is now possible to pass a custom `<Template />` component as a prop to `<KcApp />` and every
|
||||
individual page (`<Login />`, `<RegisterUserProfile />`, ...) it enables to customize only the header and footer for
|
||||
example without having to switch to a full-component level customization. [See issue](https://github.com/InseeFrLab/keycloakify/issues/191).
|
||||
example without having to switch to a full-component level customization. [See issue](https://github.com/keycloakify/keycloakify/issues/191).
|
||||
|
||||
## 6.7.0
|
||||
|
||||
- Add support for `webauthn-authenticate.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/InseeFrLab/keycloakify/pull/185).
|
||||
- Add support for `webauthn-authenticate.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/185).
|
||||
|
||||
## 6.6.0
|
||||
|
||||
- Add support for `login-password.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/InseeFrLab/keycloakify/pull/184).
|
||||
- Add support for `login-password.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/184).
|
||||
|
||||
## 6.5.0
|
||||
|
||||
- Add support for `login-username.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/InseeFrLab/keycloakify/pull/183).
|
||||
- Add support for `login-username.ftl` thanks to [@mstrodl](https://github.com/Mstrodl)'s hacktoberfest [PR](https://github.com/keycloakify/keycloakify/pull/183).
|
||||
|
||||
## 6.4.0
|
||||
|
||||
@ -108,11 +114,11 @@ Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6)
|
||||
|
||||
## 5.8.0
|
||||
|
||||
- [React.lazy()](https://reactjs.org/docs/code-splitting.html#reactlazy) support 🎉. [#141](https://github.com/InseeFrLab/keycloakify/issues/141)
|
||||
- [React.lazy()](https://reactjs.org/docs/code-splitting.html#reactlazy) support 🎉. [#141](https://github.com/keycloakify/keycloakify/issues/141)
|
||||
|
||||
## 5.7.0
|
||||
|
||||
- Feat `logout-confirm.ftl`. [PR](https://github.com/InseeFrLab/keycloakify/pull/120)
|
||||
- Feat `logout-confirm.ftl`. [PR](https://github.com/keycloakify/keycloakify/pull/120)
|
||||
|
||||
## 5.6.4
|
||||
|
||||
@ -120,7 +126,7 @@ Fix `login-verify-email.ftl` page. [Before](https://user-images.githubuserconten
|
||||
|
||||
## v5.6.0
|
||||
|
||||
Add support for `login-config-totp.ftl` page [#127](https://github.com/InseeFrLab/keycloakify/pull/127).
|
||||
Add support for `login-config-totp.ftl` page [#127](https://github.com/keycloakify/keycloakify/pull/127).
|
||||
|
||||
## v5.3.0
|
||||
|
||||
@ -135,7 +141,7 @@ Import of terms and services have changed. [See example](https://github.com/garr
|
||||
|
||||
## v4.10.0
|
||||
|
||||
Add `login-idp-link-email.ftl` page [See PR](https://github.com/InseeFrLab/keycloakify/pull/92).
|
||||
Add `login-idp-link-email.ftl` page [See PR](https://github.com/keycloakify/keycloakify/pull/92).
|
||||
|
||||
## v4.8.0
|
||||
|
||||
@ -148,7 +154,7 @@ Add `login-idp-link-email.ftl` page [See PR](https://github.com/InseeFrLab/keycl
|
||||
## v4.7.2
|
||||
|
||||
> WARNING: This is broken.
|
||||
> Testing with local Keycloak container working with M1 Mac. Thanks to [@eduardosanzb](https://github.com/InseeFrLab/keycloakify/issues/43#issuecomment-975699658).
|
||||
> Testing with local Keycloak container working with M1 Mac. Thanks to [@eduardosanzb](https://github.com/keycloakify/keycloakify/issues/43#issuecomment-975699658).
|
||||
> Be aware: When running M1s you are testing with Keycloak v15 else the local container spun will be a Keycloak v16.1.0.
|
||||
|
||||
## v4.7.0
|
||||
@ -182,12 +188,12 @@ Change [this](https://github.com/garronej/keycloakify-demo-app/blob/df664c13c77c
|
||||
|
||||
No breaking changes except that `@emotion/react`, [`tss-react`](https://www.npmjs.com/package/tss-react) and [`powerhooks`](https://www.npmjs.com/package/powerhooks) are now `peerDependencies` instead of being just dependencies.
|
||||
It's important to avoid problem when using `keycloakify` alongside [`mui`](https://mui.com) and
|
||||
[when passing params from the app to the login page](https://github.com/InseeFrLab/keycloakify#implement-context-persistence-optional).
|
||||
[when passing params from the app to the login page](https://github.com/keycloakify/keycloakify#implement-context-persistence-optional).
|
||||
|
||||
## v2.5
|
||||
|
||||
- Feature [Use advanced message](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/i18n/useKcMessage.tsx#L53-L66)
|
||||
and [`messagesPerFields`](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/getKcContext/KcContextBase.ts#L70-L75) (implementation [here](https://github.com/InseeFrLab/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/bin/build-keycloak-theme/generateFtl/common.ftl#L130-L189))
|
||||
- Feature [Use advanced message](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/i18n/useKcMessage.tsx#L53-L66)
|
||||
and [`messagesPerFields`](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/lib/getKcContext/KcContextBase.ts#L70-L75) (implementation [here](https://github.com/keycloakify/keycloakify/blob/59f106bf9e210b63b190826da2bf5f75fc8b7644/src/bin/build-keycloak-theme/generateFtl/common.ftl#L130-L189))
|
||||
- Test container now uses Keycloak version `15.0.2`.
|
||||
|
||||
## v2
|
||||
|
15
package.json
15
package.json
@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "keycloakify",
|
||||
"version": "7.0.2",
|
||||
"version": "7.4.0",
|
||||
"description": "Create Keycloak themes using React",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/inseefrlab/keycloakify.git"
|
||||
"url": "git://github.com/keycloakify/keycloakify.git"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@ -12,11 +12,11 @@
|
||||
"prepare": "yarn generate-i18n-messages",
|
||||
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src/tsconfig.json && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/",
|
||||
"build:watch": "tsc -p src/tsconfig.json && (concurrently \"tsc -p src/tsconfig.json -w\" \"tsc-alias -p src/tsconfig.json\")",
|
||||
"build:test": "rimraf dist_test/ && tsc -p test/tsconfig.json && tsc-alias -p test/tsconfig.json && yarn copy-files dist_test/src",
|
||||
"grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js",
|
||||
"copy-files": "copyfiles -u 1 src/**/*.ftl",
|
||||
"test": "yarn build:test && node dist_test/test/bin && node dist_test/test/lib",
|
||||
"test:sample-app": "yarn build:test && node dist_test/test/bin/main.js",
|
||||
"test": "yarn test:types && vitest run",
|
||||
"test:keycloakify-starter": "ts-node scripts/test-keycloakify-starter",
|
||||
"test:types": "tsc -p test/tsconfig.json --noEmit",
|
||||
"_format": "prettier '**/*.{ts,tsx,json,md}'",
|
||||
"format": "yarn _format --write",
|
||||
"format:check": "yarn _format --list-different",
|
||||
@ -69,6 +69,7 @@
|
||||
"@types/minimist": "^1.2.2",
|
||||
"@types/node": "^18.15.3",
|
||||
"@types/react": "18.0.9",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"concurrently": "^7.6.0",
|
||||
"copyfiles": "^2.4.1",
|
||||
"husky": "^4.3.8",
|
||||
@ -80,7 +81,8 @@
|
||||
"scripting-tools": "^0.19.13",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsc-alias": "^1.8.3",
|
||||
"typescript": "^5.0.1-rc"
|
||||
"typescript": "^5.0.1-rc",
|
||||
"vitest": "^0.29.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@octokit/rest": "^18.12.0",
|
||||
@ -94,6 +96,7 @@
|
||||
"react-markdown": "^5.0.3",
|
||||
"rfc4648": "^1.5.2",
|
||||
"tsafe": "^1.6.0",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.17.10"
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
|
||||
import { getCliOptions } from "../src/bin/tools/cliOptions";
|
||||
import { getLogger } from "../src/bin/tools/logger";
|
||||
|
||||
//NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
|
||||
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
|
||||
// update the version array for generating for newer version.
|
||||
|
||||
//@ts-ignore
|
||||
@ -16,7 +16,7 @@ const propertiesParser = require("properties-parser");
|
||||
const { isSilent } = getCliOptions(process.argv.slice(2));
|
||||
const logger = getLogger({ isSilent });
|
||||
|
||||
(async () => {
|
||||
async function main() {
|
||||
const keycloakVersion = "21.0.1";
|
||||
|
||||
const tmpDirPath = pathJoin(getProjectRoot(), "tmp_xImOef9dOd44");
|
||||
@ -37,7 +37,9 @@ const logger = getLogger({ isSilent });
|
||||
const baseThemeDirPath = pathJoin(tmpDirPath, "base");
|
||||
|
||||
crawl(baseThemeDirPath).forEach(filePath => {
|
||||
const match = filePath.match(/^([^/]+)\/messages\/messages_([^.]+)\.properties$/);
|
||||
const match =
|
||||
filePath.match(/^([^/]+)\/messages\/messages_([^.]+)\.properties$/) ||
|
||||
filePath.match(/^([^\\]+)\\messages\\messages_([^.]+)\.properties$/);
|
||||
|
||||
if (match === null) {
|
||||
return;
|
||||
@ -114,4 +116,8 @@ const logger = getLogger({ isSilent });
|
||||
)
|
||||
);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(e => console.error(e));
|
||||
}
|
||||
|
29
scripts/test-keycloakify-starter.ts
Normal file
29
scripts/test-keycloakify-starter.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { execSync } from "child_process";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
const testDir = "keycloakify_starter_test";
|
||||
|
||||
if (existsSync(path.join(process.cwd(), testDir))) {
|
||||
rmSync(path.join(process.cwd(), testDir), { recursive: true });
|
||||
}
|
||||
// Build and link package
|
||||
execSync("yarn build");
|
||||
const pkgJSON = JSON.parse(readFileSync(path.join(process.cwd(), "package.json")).toString("utf8"));
|
||||
pkgJSON.main = "./index.js";
|
||||
pkgJSON.types = "./index.d.ts";
|
||||
pkgJSON.scripts.prepare = undefined;
|
||||
writeFileSync(path.join(process.cwd(), "dist", "package.json"), JSON.stringify(pkgJSON));
|
||||
// Wrapped in a try/catch because unlink errors if the package isn't linked
|
||||
try {
|
||||
execSync("yarn unlink");
|
||||
} catch {}
|
||||
execSync("yarn link", { "cwd": path.join(process.cwd(), "dist") });
|
||||
|
||||
// Clone latest keycloakify-starter and link to keycloakify output
|
||||
execSync(`git clone https://github.com/keycloakify/keycloakify-starter.git ${testDir}`);
|
||||
execSync("yarn install", { "cwd": path.join(process.cwd(), testDir) });
|
||||
execSync("yarn link keycloakify", { "cwd": path.join(process.cwd(), testDir) });
|
||||
|
||||
//Ensure keycloak theme can be built
|
||||
execSync("yarn build-keycloak-theme", { "cwd": path.join(process.cwd(), testDir) });
|
@ -1,42 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { keycloakThemeBuildingDirPath } from "./keycloakify";
|
||||
import { join as pathJoin } from "path";
|
||||
import { downloadAndUnzip } from "./tools/downloadAndUnzip";
|
||||
import { promptKeycloakVersion } from "./promptKeycloakVersion";
|
||||
import { getCliOptions } from "./tools/cliOptions";
|
||||
import { getLogger } from "./tools/logger";
|
||||
import { getKeycloakBuildPath } from "./keycloakify/build-paths";
|
||||
|
||||
export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: string; destDirPath: string; isSilent: boolean }) {
|
||||
const { keycloakVersion, destDirPath, isSilent } = params;
|
||||
const { 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`,
|
||||
"cacheDirPath": pathJoin(keycloakThemeBuildingDirPath, ".cache"),
|
||||
isSilent
|
||||
"pathOfDirToExtractInArchive": `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
const { isSilent } = getCliOptions(process.argv.slice(2));
|
||||
const logger = getLogger({ isSilent });
|
||||
const { keycloakVersion } = await promptKeycloakVersion();
|
||||
async function main() {
|
||||
const { isSilent } = getCliOptions(process.argv.slice(2));
|
||||
const logger = getLogger({ isSilent });
|
||||
const { keycloakVersion } = await promptKeycloakVersion();
|
||||
|
||||
const destDirPath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme");
|
||||
const destDirPath = pathJoin(getKeycloakBuildPath(), "src", "main", "resources", "theme");
|
||||
|
||||
logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`);
|
||||
logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`);
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
keycloakVersion,
|
||||
destDirPath,
|
||||
isSilent
|
||||
});
|
||||
})();
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
keycloakVersion,
|
||||
destDirPath,
|
||||
isSilent
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import { existsSync } from "fs";
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
|
||||
import { assert, Equals } from "tsafe/assert";
|
||||
import { getThemeSrcDirPath } from "./keycloakify/build-paths";
|
||||
|
||||
(async () => {
|
||||
const projectRootDir = getProjectRoot();
|
||||
@ -50,7 +51,13 @@ import { assert, Equals } from "tsafe/assert";
|
||||
|
||||
const pageBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(/ftl$/, "tsx");
|
||||
|
||||
const targetFilePath = pathJoin(process.cwd(), "src", "keycloak-theme", themeType, "pages", pageBasename);
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath();
|
||||
|
||||
if (themeSrcDirPath === undefined) {
|
||||
throw new Error("Couldn't locate your theme sources");
|
||||
}
|
||||
|
||||
const targetFilePath = pathJoin(themeSrcDirPath, themeType, "pages", pageBasename);
|
||||
|
||||
if (existsSync(targetFilePath)) {
|
||||
console.log(`${pageId} is already ejected, ${pathRelative(process.cwd(), targetFilePath)} already exists`);
|
||||
|
@ -1,27 +1,35 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { downloadBuiltinKeycloakTheme } from "./download-builtin-keycloak-theme";
|
||||
import { keycloakThemeEmailDirPath } from "./keycloakify";
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
import { transformCodebase } from "./tools/transformCodebase";
|
||||
import { promptKeycloakVersion } from "./promptKeycloakVersion";
|
||||
import * as fs from "fs";
|
||||
import { getCliOptions } from "./tools/cliOptions";
|
||||
import { getLogger } from "./tools/logger";
|
||||
import { getEmailThemeSrcDirPath } from "./keycloakify/build-paths";
|
||||
|
||||
(async () => {
|
||||
export async function main() {
|
||||
const { isSilent } = getCliOptions(process.argv.slice(2));
|
||||
const logger = getLogger({ isSilent });
|
||||
|
||||
if (fs.existsSync(keycloakThemeEmailDirPath)) {
|
||||
logger.warn(`There is already a ${pathRelative(process.cwd(), keycloakThemeEmailDirPath)} directory in your project. Aborting.`);
|
||||
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath();
|
||||
|
||||
if (emailThemeSrcDirPath === undefined) {
|
||||
logger.warn("Couldn't locate your theme source directory");
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
if (fs.existsSync(emailThemeSrcDirPath)) {
|
||||
logger.warn(`There is already a ${pathRelative(process.cwd(), emailThemeSrcDirPath)} directory in your project. Aborting.`);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
const { keycloakVersion } = await promptKeycloakVersion();
|
||||
|
||||
const builtinKeycloakThemeTmpDirPath = pathJoin(keycloakThemeEmailDirPath, "..", "tmp_xIdP3_builtin_keycloak_theme");
|
||||
const builtinKeycloakThemeTmpDirPath = pathJoin(emailThemeSrcDirPath, "..", "tmp_xIdP3_builtin_keycloak_theme");
|
||||
|
||||
await downloadBuiltinKeycloakTheme({
|
||||
keycloakVersion,
|
||||
@ -31,18 +39,20 @@ import { getLogger } from "./tools/logger";
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "base", "email"),
|
||||
"destDirPath": keycloakThemeEmailDirPath
|
||||
"destDirPath": emailThemeSrcDirPath
|
||||
});
|
||||
|
||||
{
|
||||
const themePropertyFilePath = pathJoin(keycloakThemeEmailDirPath, "theme.properties");
|
||||
const themePropertyFilePath = pathJoin(emailThemeSrcDirPath, "theme.properties");
|
||||
|
||||
fs.writeFileSync(themePropertyFilePath, Buffer.from(`parent=base\n${fs.readFileSync(themePropertyFilePath).toString("utf8")}`, "utf8"));
|
||||
}
|
||||
|
||||
logger.log(
|
||||
`${pathRelative(process.cwd(), keycloakThemeEmailDirPath)} ready to be customized, feel free to remove every file you do not customize`
|
||||
);
|
||||
logger.log(`${pathRelative(process.cwd(), emailThemeSrcDirPath)} ready to be customized, feel free to remove every file you do not customize`);
|
||||
|
||||
fs.rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true, "force": true });
|
||||
})();
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
@ -1,49 +1,11 @@
|
||||
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";
|
||||
import { typeGuard } from "tsafe/typeGuard";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
|
||||
const bundlers = ["mvn", "keycloakify", "none"] as const;
|
||||
type Bundler = (typeof bundlers)[number];
|
||||
type ParsedPackageJson = {
|
||||
name: string;
|
||||
version: string;
|
||||
homepage?: string;
|
||||
keycloakify?: {
|
||||
/** @deprecated: use extraLoginPages instead */
|
||||
extraPages?: string[];
|
||||
extraLoginPages?: string[];
|
||||
extraAccountPages?: string[];
|
||||
extraThemeProperties?: string[];
|
||||
areAppAndKeycloakServerSharingSameDomain?: boolean;
|
||||
artifactId?: string;
|
||||
groupId?: string;
|
||||
bundler?: Bundler;
|
||||
};
|
||||
};
|
||||
|
||||
const zParsedPackageJson = z.object({
|
||||
"name": z.string(),
|
||||
"version": z.string(),
|
||||
"homepage": z.string().optional(),
|
||||
"keycloakify": z
|
||||
.object({
|
||||
"extraPages": z.array(z.string()).optional(),
|
||||
"extraLoginPages": z.array(z.string()).optional(),
|
||||
"extraAccountPages": z.array(z.string()).optional(),
|
||||
"extraThemeProperties": z.array(z.string()).optional(),
|
||||
"areAppAndKeycloakServerSharingSameDomain": z.boolean().optional(),
|
||||
"artifactId": z.string().optional(),
|
||||
"groupId": z.string().optional(),
|
||||
"bundler": z.enum(bundlers).optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
assert<Equals<ReturnType<(typeof zParsedPackageJson)["parse"]>, ParsedPackageJson>>();
|
||||
import { bundlers, getParsedPackageJson } from "./parsed-package-json";
|
||||
import type { Bundler } from "./parsed-package-json";
|
||||
import { getAppInputPath, getKeycloakBuildPath } from "./build-paths";
|
||||
|
||||
/** Consolidated build option gathered form CLI arguments and config in package.json */
|
||||
export type BuildOptions = BuildOptions.Standalone | BuildOptions.ExternalAssets;
|
||||
@ -59,6 +21,12 @@ export namespace BuildOptions {
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
bundler: Bundler;
|
||||
keycloakVersionDefaultAssets: string;
|
||||
// Directory of your built react project. Defaults to {cwd}/build
|
||||
appInputPath: string;
|
||||
// Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak
|
||||
keycloakBuildPath: string;
|
||||
customUserAttributes: string[];
|
||||
};
|
||||
|
||||
export type Standalone = Common & {
|
||||
@ -85,15 +53,10 @@ export namespace BuildOptions {
|
||||
}
|
||||
}
|
||||
|
||||
export function readBuildOptions(params: {
|
||||
packageJson: string;
|
||||
CNAME: string | undefined;
|
||||
isExternalAssetsCliParamProvided: boolean;
|
||||
isSilent: boolean;
|
||||
}): BuildOptions {
|
||||
const { packageJson, CNAME, isExternalAssetsCliParamProvided, isSilent } = params;
|
||||
export function readBuildOptions(params: { CNAME: string | undefined; isExternalAssetsCliParamProvided: boolean; isSilent: boolean }): BuildOptions {
|
||||
const { CNAME, isExternalAssetsCliParamProvided, isSilent } = params;
|
||||
|
||||
const parsedPackageJson = zParsedPackageJson.parse(JSON.parse(packageJson));
|
||||
const parsedPackageJson = getParsedPackageJson();
|
||||
|
||||
const url = (() => {
|
||||
const { homepage } = parsedPackageJson;
|
||||
@ -125,7 +88,8 @@ export function readBuildOptions(params: {
|
||||
const common: BuildOptions.Common = (() => {
|
||||
const { name, keycloakify = {}, version, homepage } = parsedPackageJson;
|
||||
|
||||
const { extraPages, extraLoginPages, extraAccountPages, extraThemeProperties, groupId, artifactId, bundler } = keycloakify ?? {};
|
||||
const { extraPages, extraLoginPages, extraAccountPages, extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets } =
|
||||
keycloakify ?? {};
|
||||
|
||||
const themeName = name
|
||||
.replace(/^@(.*)/, "$1")
|
||||
@ -167,7 +131,11 @@ export function readBuildOptions(params: {
|
||||
"extraLoginPages": [...(extraPages ?? []), ...(extraLoginPages ?? [])],
|
||||
extraAccountPages,
|
||||
extraThemeProperties,
|
||||
isSilent
|
||||
isSilent,
|
||||
"keycloakVersionDefaultAssets": keycloakVersionDefaultAssets ?? "11.0.3",
|
||||
appInputPath: getAppInputPath(),
|
||||
keycloakBuildPath: getKeycloakBuildPath(),
|
||||
"customUserAttributes": keycloakify.customUserAttributes ?? []
|
||||
};
|
||||
})();
|
||||
|
||||
|
72
src/bin/keycloakify/build-paths.ts
Normal file
72
src/bin/keycloakify/build-paths.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import * as fs from "fs";
|
||||
import { exclude } from "tsafe";
|
||||
import { crawl } from "../tools/crawl";
|
||||
import { pathJoin } from "../tools/pathJoin";
|
||||
import { getParsedPackageJson } from "./parsed-package-json";
|
||||
|
||||
const DEFAULT_APP_INPUT_PATH = "build";
|
||||
|
||||
const DEFAULT_KEYCLOAK_BUILD_PATH = "build_keycloak";
|
||||
|
||||
const THEME_SRC_DIR_BASENAME = "keycloak-theme";
|
||||
|
||||
export const getReactProjectDirPath = () => process.cwd();
|
||||
|
||||
export const getCnamePath = () => pathJoin(getReactProjectDirPath(), "public", "CNAME");
|
||||
|
||||
const parseAppInputPath = (path?: string) => {
|
||||
if (!path) {
|
||||
return pathJoin(process.cwd(), DEFAULT_APP_INPUT_PATH);
|
||||
} else if (path.startsWith("./")) {
|
||||
return pathJoin(process.cwd(), path.replace("./", ""));
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const parseKeycloakBuildPath = (path?: string) => {
|
||||
if (!path) {
|
||||
return pathJoin(process.cwd(), DEFAULT_KEYCLOAK_BUILD_PATH);
|
||||
} else if (path.startsWith("./")) {
|
||||
return pathJoin(process.cwd(), path.replace("./", ""));
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
export const getAppInputPath = () => {
|
||||
return parseAppInputPath(getParsedPackageJson().keycloakify?.appInputPath);
|
||||
};
|
||||
|
||||
export const getKeycloakBuildPath = () => {
|
||||
return parseKeycloakBuildPath(getParsedPackageJson().keycloakify?.keycloakBuildPath);
|
||||
};
|
||||
export const getThemeSrcDirPath = () => {
|
||||
const srcDirPath = pathJoin(getReactProjectDirPath(), "src");
|
||||
|
||||
const themeSrcDirPath: string | undefined = crawl(srcDirPath)
|
||||
.map(fileRelativePath => {
|
||||
const split = fileRelativePath.split(THEME_SRC_DIR_BASENAME);
|
||||
|
||||
if (split.length !== 2) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return pathJoin(srcDirPath, split[0] + THEME_SRC_DIR_BASENAME);
|
||||
})
|
||||
.filter(exclude(undefined))[0];
|
||||
if (themeSrcDirPath === undefined) {
|
||||
if (fs.existsSync(pathJoin(srcDirPath, "login")) || fs.existsSync(pathJoin(srcDirPath, "account"))) {
|
||||
return { "themeSrcDirPath": srcDirPath };
|
||||
}
|
||||
return { "themeSrcDirPath": undefined };
|
||||
}
|
||||
|
||||
return { themeSrcDirPath };
|
||||
};
|
||||
|
||||
export const getEmailThemeSrcDirPath = () => {
|
||||
const { themeSrcDirPath } = getThemeSrcDirPath();
|
||||
|
||||
const emailThemeSrcDirPath = themeSrcDirPath === undefined ? undefined : pathJoin(themeSrcDirPath, "email");
|
||||
|
||||
return { emailThemeSrcDirPath };
|
||||
};
|
@ -13,7 +13,7 @@
|
||||
"totp", "totpSecret", "SAMLRequest", "SAMLResponse", "relayState", "device_user_code", "code",
|
||||
"password-new", "rememberMe", "login", "authenticationExecution", "cancel-aia", "clientDataJSON",
|
||||
"authenticatorData", "signature", "credentialId", "userHandle", "error", "authn_use_chk", "authenticationExecution",
|
||||
"isSetRetry", "try-again", "attestationObject", "publicKeyCredentialId", "authenticatorLabel"
|
||||
"isSetRetry", "try-again", "attestationObject", "publicKeyCredentialId", "authenticatorLabel"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM
|
||||
]>
|
||||
|
||||
<#attempt>
|
||||
@ -164,9 +164,9 @@
|
||||
key == "updateProfileCtx" &&
|
||||
are_same_path(path, [])
|
||||
) || (
|
||||
<#-- https://github.com/InseeFrLab/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
|
||||
<#-- https://github.com/InseeFrLab/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
|
||||
<#-- https://github.com/InseeFrLab/keycloakify/issues/109#issuecomment-1134610163 -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/pull/65#issuecomment-991896344 (reports with saml-post-form.ftl) -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
|
||||
key == "loginAction" &&
|
||||
are_same_path(path, ["url"]) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl"]?seq_contains(pageId) &&
|
||||
|
@ -8,7 +8,6 @@ import { objectKeys } from "tsafe/objectKeys";
|
||||
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
|
||||
import type { BuildOptions } from "../BuildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
|
||||
export const themeTypes = ["login", "account"] as const;
|
||||
|
||||
@ -35,7 +34,8 @@ export const loginThemePageIds = [
|
||||
"login-config-totp.ftl",
|
||||
"logout-confirm.ftl",
|
||||
"update-user-profile.ftl",
|
||||
"idp-review-user-profile.ftl"
|
||||
"idp-review-user-profile.ftl",
|
||||
"update-email.ftl"
|
||||
] as const;
|
||||
|
||||
export const accountThemePageIds = ["password.ftl", "account.ftl"] as const;
|
||||
@ -46,7 +46,11 @@ export type AccountThemePageId = (typeof accountThemePageIds)[number];
|
||||
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
|
||||
|
||||
export namespace BuildOptionsLike {
|
||||
export type Standalone = {
|
||||
export type Common = {
|
||||
customUserAttributes: string[];
|
||||
};
|
||||
|
||||
export type Standalone = Common & {
|
||||
isStandalone: true;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
@ -58,23 +62,21 @@ export namespace BuildOptionsLike {
|
||||
isStandalone: false;
|
||||
};
|
||||
|
||||
export type SameDomain = CommonExternalAssets & {
|
||||
areAppAndKeycloakServerSharingSameDomain: true;
|
||||
};
|
||||
export type SameDomain = Common &
|
||||
CommonExternalAssets & {
|
||||
areAppAndKeycloakServerSharingSameDomain: true;
|
||||
};
|
||||
|
||||
export type DifferentDomains = CommonExternalAssets & {
|
||||
areAppAndKeycloakServerSharingSameDomain: false;
|
||||
urlOrigin: string;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
export type DifferentDomains = Common &
|
||||
CommonExternalAssets & {
|
||||
areAppAndKeycloakServerSharingSameDomain: false;
|
||||
urlOrigin: string;
|
||||
urlPathname: string | undefined;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export function generateFtlFilesCodeFactory(params: {
|
||||
indexHtmlCode: string;
|
||||
@ -152,7 +154,11 @@ export function generateFtlFilesCodeFactory(params: {
|
||||
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }': fs
|
||||
.readFileSync(pathJoin(__dirname, "ftl_object_to_js_code_declaring_an_object.ftl"))
|
||||
.toString("utf8")
|
||||
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1],
|
||||
.match(/^<script>const _=((?:.|\n)+)<\/script>[\n]?$/)![1]
|
||||
.replace(
|
||||
"CUSTOM_USER_ATTRIBUTES_eKsIY4ZsZ4xeM",
|
||||
buildOptions.customUserAttributes.length === 0 ? "" : ", " + buildOptions.customUserAttributes.map(name => `"${name}"`).join(", ")
|
||||
),
|
||||
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
|
||||
"<#if scripts??>",
|
||||
" <#list scripts as script>",
|
||||
|
@ -9,8 +9,6 @@ import { mockTestingResourcesCommonPath, mockTestingResourcesPath, mockTestingSu
|
||||
import { isInside } from "../tools/isInside";
|
||||
import type { BuildOptions } from "./BuildOptions";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
import { getLogger } from "../tools/logger";
|
||||
|
||||
export type BuildOptionsLike = BuildOptionsLike.Standalone | BuildOptionsLike.ExternalAssets;
|
||||
|
||||
@ -21,6 +19,7 @@ export namespace BuildOptionsLike {
|
||||
extraAccountPages?: string[];
|
||||
extraThemeProperties?: string[];
|
||||
isSilent: boolean;
|
||||
customUserAttributes: string[];
|
||||
};
|
||||
|
||||
export type Standalone = Common & {
|
||||
@ -47,22 +46,16 @@ export namespace BuildOptionsLike {
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const buildOptions = Reflect<BuildOptions>();
|
||||
|
||||
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
|
||||
}
|
||||
assert<BuildOptions extends BuildOptionsLike ? true : false>();
|
||||
|
||||
export async function generateKeycloakThemeResources(params: {
|
||||
reactAppBuildDirPath: string;
|
||||
keycloakThemeBuildingDirPath: string;
|
||||
keycloakThemeEmailDirPath: string;
|
||||
emailThemeSrcDirPath: string | undefined;
|
||||
keycloakVersion: string;
|
||||
buildOptions: BuildOptionsLike;
|
||||
}): Promise<{ doBundlesEmailTemplate: boolean }> {
|
||||
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, keycloakThemeEmailDirPath, keycloakVersion, buildOptions } = params;
|
||||
|
||||
const logger = getLogger({ isSilent: buildOptions.isSilent });
|
||||
const { reactAppBuildDirPath, keycloakThemeBuildingDirPath, emailThemeSrcDirPath, keycloakVersion, buildOptions } = params;
|
||||
|
||||
const getThemeDirPath = (themeType: ThemeType | "email") =>
|
||||
pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType);
|
||||
@ -145,7 +138,7 @@ export async function generateKeycloakThemeResources(params: {
|
||||
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
|
||||
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
|
||||
"cssGlobalsToDefine": allCssGlobalsToDefine,
|
||||
"buildOptions": buildOptions
|
||||
buildOptions
|
||||
});
|
||||
|
||||
return generateFtlFilesCode;
|
||||
@ -228,13 +221,7 @@ export async function generateKeycloakThemeResources(params: {
|
||||
let doBundlesEmailTemplate: boolean;
|
||||
|
||||
email: {
|
||||
if (!fs.existsSync(keycloakThemeEmailDirPath)) {
|
||||
logger.log(
|
||||
[
|
||||
`Not bundling email template because ${pathBasename(keycloakThemeEmailDirPath)} does not exist`,
|
||||
`To start customizing the email template, run: 👉 npx create-keycloak-email-directory 👈`
|
||||
].join("\n")
|
||||
);
|
||||
if (emailThemeSrcDirPath === undefined) {
|
||||
doBundlesEmailTemplate = false;
|
||||
break email;
|
||||
}
|
||||
@ -242,7 +229,7 @@ export async function generateKeycloakThemeResources(params: {
|
||||
doBundlesEmailTemplate = true;
|
||||
|
||||
transformCodebase({
|
||||
"srcDirPath": keycloakThemeEmailDirPath,
|
||||
"srcDirPath": emailThemeSrcDirPath,
|
||||
"destDirPath": getThemeDirPath("email")
|
||||
});
|
||||
}
|
||||
|
@ -4,5 +4,5 @@ export * from "./keycloakify";
|
||||
import { main } from "./keycloakify";
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(e => console.error(e));
|
||||
main();
|
||||
}
|
||||
|
@ -9,12 +9,9 @@ import { getLogger } from "../tools/logger";
|
||||
import { getCliOptions } from "../tools/cliOptions";
|
||||
import jar from "../tools/jar";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
|
||||
const reactProjectDirPath = process.cwd();
|
||||
|
||||
export const keycloakThemeBuildingDirPath = pathJoin(reactProjectDirPath, "build_keycloak");
|
||||
export const keycloakThemeEmailDirPath = pathJoin(reactProjectDirPath, "src", "keycloak-theme", "email");
|
||||
import { Equals } from "tsafe";
|
||||
import { getEmailThemeSrcDirPath } from "./build-paths";
|
||||
import { getCnamePath, getAppInputPath, getKeycloakBuildPath, getReactProjectDirPath } from "./build-paths";
|
||||
|
||||
export async function main() {
|
||||
const { isSilent, hasExternalAssets } = getCliOptions(process.argv.slice(2));
|
||||
@ -22,9 +19,8 @@ export async function main() {
|
||||
logger.log("🔏 Building the keycloak theme...⌚");
|
||||
|
||||
const buildOptions = readBuildOptions({
|
||||
"packageJson": fs.readFileSync(pathJoin(reactProjectDirPath, "package.json")).toString("utf8"),
|
||||
"CNAME": (() => {
|
||||
const cnameFilePath = pathJoin(reactProjectDirPath, "public", "CNAME");
|
||||
const cnameFilePath = getCnamePath();
|
||||
|
||||
if (!fs.existsSync(cnameFilePath)) {
|
||||
return undefined;
|
||||
@ -37,18 +33,23 @@ export async function main() {
|
||||
});
|
||||
|
||||
const { doBundlesEmailTemplate } = await generateKeycloakThemeResources({
|
||||
keycloakThemeBuildingDirPath,
|
||||
keycloakThemeEmailDirPath,
|
||||
"reactAppBuildDirPath": pathJoin(reactProjectDirPath, "build"),
|
||||
keycloakThemeBuildingDirPath: buildOptions.keycloakBuildPath,
|
||||
"emailThemeSrcDirPath": (() => {
|
||||
const { emailThemeSrcDirPath } = getEmailThemeSrcDirPath();
|
||||
|
||||
if (emailThemeSrcDirPath === undefined || !fs.existsSync(emailThemeSrcDirPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return emailThemeSrcDirPath;
|
||||
})(),
|
||||
"reactAppBuildDirPath": getAppInputPath(),
|
||||
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.
|
||||
"keycloakVersion": "11.0.3"
|
||||
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets
|
||||
});
|
||||
|
||||
const { jarFilePath } = generateJavaStackFiles({
|
||||
keycloakThemeBuildingDirPath,
|
||||
keycloakThemeBuildingDirPath: buildOptions.keycloakBuildPath,
|
||||
doBundlesEmailTemplate,
|
||||
buildOptions
|
||||
});
|
||||
@ -60,7 +61,7 @@ export async function main() {
|
||||
case "keycloakify":
|
||||
logger.log("🫶 Let keycloakify do its thang");
|
||||
await jar({
|
||||
"rootPath": pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources"),
|
||||
"rootPath": pathJoin(buildOptions.keycloakBuildPath, "src", "main", "resources"),
|
||||
"version": buildOptions.version,
|
||||
"groupId": buildOptions.groupId,
|
||||
"artifactId": buildOptions.artifactId,
|
||||
@ -69,7 +70,7 @@ export async function main() {
|
||||
break;
|
||||
case "mvn":
|
||||
logger.log("🫙 Run maven to deliver a jar");
|
||||
child_process.execSync("mvn package", { "cwd": keycloakThemeBuildingDirPath });
|
||||
child_process.execSync("mvn package", { "cwd": buildOptions.keycloakBuildPath });
|
||||
break;
|
||||
default:
|
||||
assert<Equals<typeof buildOptions.bundler, never>>(false);
|
||||
@ -79,7 +80,7 @@ export async function main() {
|
||||
const containerKeycloakVersion = "20.0.1";
|
||||
|
||||
generateStartKeycloakTestingContainer({
|
||||
keycloakThemeBuildingDirPath,
|
||||
keycloakThemeBuildingDirPath: buildOptions.keycloakBuildPath,
|
||||
"keycloakVersion": containerKeycloakVersion,
|
||||
buildOptions
|
||||
});
|
||||
@ -87,7 +88,7 @@ export async function main() {
|
||||
logger.log(
|
||||
[
|
||||
"",
|
||||
`✅ Your keycloak theme has been generated and bundled into ./${pathRelative(reactProjectDirPath, jarFilePath)} 🚀`,
|
||||
`✅ Your keycloak theme has been generated and bundled into ./${pathRelative(getReactProjectDirPath(), jarFilePath)} 🚀`,
|
||||
`It is to be placed in "/opt/keycloak/providers" in the container running a quay.io/keycloak/keycloak Docker image.`,
|
||||
"",
|
||||
//TODO: Restore when we find a good Helm chart for Keycloak.
|
||||
@ -122,8 +123,8 @@ export async function main() {
|
||||
`To test your theme locally you can spin up a Keycloak ${containerKeycloakVersion} container image with the theme pre loaded by running:`,
|
||||
"",
|
||||
`👉 $ .${pathSep}${pathRelative(
|
||||
reactProjectDirPath,
|
||||
pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename)
|
||||
getReactProjectDirPath(),
|
||||
pathJoin(getKeycloakBuildPath(), generateStartKeycloakTestingContainer.basename)
|
||||
)} 👈`,
|
||||
"",
|
||||
`Test with different Keycloak versions by editing the .sh file. see available versions here: https://quay.io/repository/keycloak/keycloak?tab=tags`,
|
||||
|
60
src/bin/keycloakify/parsed-package-json.ts
Normal file
60
src/bin/keycloakify/parsed-package-json.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import * as fs from "fs";
|
||||
import { assert } from "tsafe";
|
||||
import type { Equals } from "tsafe";
|
||||
import { z } from "zod";
|
||||
import { pathJoin } from "../tools/pathJoin";
|
||||
|
||||
const reactProjectDirPath = process.cwd();
|
||||
export const bundlers = ["mvn", "keycloakify", "none"] as const;
|
||||
export type Bundler = (typeof bundlers)[number];
|
||||
type ParsedPackageJson = {
|
||||
name: string;
|
||||
version: string;
|
||||
homepage?: string;
|
||||
keycloakify?: {
|
||||
/** @deprecated: use extraLoginPages instead */
|
||||
extraPages?: string[];
|
||||
extraLoginPages?: string[];
|
||||
extraAccountPages?: string[];
|
||||
extraThemeProperties?: string[];
|
||||
areAppAndKeycloakServerSharingSameDomain?: boolean;
|
||||
artifactId?: string;
|
||||
groupId?: string;
|
||||
bundler?: Bundler;
|
||||
keycloakVersionDefaultAssets?: string;
|
||||
appInputPath?: string;
|
||||
keycloakBuildPath?: string;
|
||||
customUserAttributes?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const zParsedPackageJson = z.object({
|
||||
"name": z.string(),
|
||||
"version": z.string(),
|
||||
"homepage": z.string().optional(),
|
||||
"keycloakify": z
|
||||
.object({
|
||||
"extraPages": z.array(z.string()).optional(),
|
||||
"extraLoginPages": z.array(z.string()).optional(),
|
||||
"extraAccountPages": z.array(z.string()).optional(),
|
||||
"extraThemeProperties": z.array(z.string()).optional(),
|
||||
"areAppAndKeycloakServerSharingSameDomain": z.boolean().optional(),
|
||||
"artifactId": z.string().optional(),
|
||||
"groupId": z.string().optional(),
|
||||
"bundler": z.enum(bundlers).optional(),
|
||||
"keycloakVersionDefaultAssets": z.string().optional(),
|
||||
"appInputPath": z.string().optional(),
|
||||
"keycloakBuildPath": z.string().optional(),
|
||||
"customUserAttributes": z.array(z.string()).optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
assert<Equals<ReturnType<(typeof zParsedPackageJson)["parse"]>, ParsedPackageJson>>();
|
||||
|
||||
let parsedPackageJson: undefined | ReturnType<(typeof zParsedPackageJson)["parse"]>;
|
||||
export const getParsedPackageJson = () => {
|
||||
if (parsedPackageJson) return parsedPackageJson;
|
||||
parsedPackageJson = zParsedPackageJson.parse(JSON.parse(fs.readFileSync(pathJoin(reactProjectDirPath, "package.json")).toString("utf8")));
|
||||
return parsedPackageJson;
|
||||
};
|
@ -1,15 +1,13 @@
|
||||
import { dirname as pathDirname, basename as pathBasename, join as pathJoin, join } from "path";
|
||||
import { createReadStream, createWriteStream } from "fs";
|
||||
import { stat, mkdir, unlink, writeFile } from "fs/promises";
|
||||
import { transformCodebase } from "./transformCodebase";
|
||||
import { createHash } from "crypto";
|
||||
import fetch from "make-fetch-happen";
|
||||
import { createInflateRaw } from "zlib";
|
||||
import type { Readable } from "stream";
|
||||
import { homedir } from "os";
|
||||
import { FetchOptions } from "make-fetch-happen";
|
||||
import { exec as execCallback } from "child_process";
|
||||
import { createHash } from "crypto";
|
||||
import { mkdir, stat, writeFile } from "fs/promises";
|
||||
import fetch, { type FetchOptions } from "make-fetch-happen";
|
||||
import { dirname as pathDirname, join as pathJoin } from "path";
|
||||
import { assert } from "tsafe";
|
||||
import { promisify } from "util";
|
||||
import { getProjectRoot } from "./getProjectRoot";
|
||||
import { transformCodebase } from "./transformCodebase";
|
||||
import { unzip } from "./unzip";
|
||||
|
||||
const exec = promisify(execCallback);
|
||||
|
||||
@ -17,25 +15,27 @@ function hash(s: string) {
|
||||
return createHash("sha256").update(s).digest("hex");
|
||||
}
|
||||
|
||||
async function maybeStat(path: string) {
|
||||
async function exists(path: string) {
|
||||
try {
|
||||
return await stat(path);
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as Error & { code: string }).code === "ENOENT") return undefined;
|
||||
if ((error as Error & { code: string }).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an npm configuration value as string, undefined if not set.
|
||||
*
|
||||
* @param key
|
||||
* @returns string or undefined
|
||||
* Get npm configuration as map
|
||||
*/
|
||||
async function getNmpConfig(key: string): Promise<string | undefined> {
|
||||
const { stdout } = await exec(`npm config get ${key}`);
|
||||
const value = stdout.trim();
|
||||
return value && value !== "null" ? value : undefined;
|
||||
async function getNmpConfig(): Promise<Record<string, string>> {
|
||||
const { stdout } = await exec("npm config get", { encoding: "utf8" });
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter(line => !line.startsWith(";"))
|
||||
.map(line => line.trim())
|
||||
.map(line => line.split("=", 2))
|
||||
.reduce((cfg, [key, value]) => ({ ...cfg, [key]: value }), {});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -45,233 +45,43 @@ async function getNmpConfig(key: string): Promise<string | undefined> {
|
||||
* @returns proxy configuration
|
||||
*/
|
||||
async function getNpmProxyConfig(): Promise<Pick<FetchOptions, "proxy" | "noProxy">> {
|
||||
const proxy = (await getNmpConfig("https-proxy")) ?? (await getNmpConfig("proxy"));
|
||||
const noProxy = (await getNmpConfig("noproxy")) ?? (await getNmpConfig("no-proxy"));
|
||||
const cfg = await getNmpConfig();
|
||||
|
||||
const proxy = cfg["https-proxy"] ?? cfg["proxy"];
|
||||
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
|
||||
|
||||
return { proxy, noProxy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from `url` to `dir`. Will try to avoid downloading existing
|
||||
* files by using the cache directory ~/.keycloakify/cache
|
||||
*
|
||||
* If the target directory does not exist, it will be created.
|
||||
*
|
||||
* If the target file exists, it will be overwritten.
|
||||
*
|
||||
* We use make-fetch-happen's internal file cache here, so we don't need to
|
||||
* worry about redownloading the same file over and over. Unfortunately, that
|
||||
* cache does not have a single file per entry, but bundles and indexes them,
|
||||
* so we still need to write the contents to the target directory (possibly
|
||||
* over and over), cause the current unzip implementation wants random access.
|
||||
*
|
||||
* @param url download url
|
||||
* @param dir target directory
|
||||
* @param filename target filename
|
||||
* @returns promise for the full path of the downloaded file
|
||||
*/
|
||||
async function download(url: string, dir: string, filename: string): Promise<string> {
|
||||
const proxyOpts = await getNpmProxyConfig();
|
||||
const cacheRoot = process.env.XDG_CACHE_HOME ?? homedir();
|
||||
const cachePath = join(cacheRoot, ".keycloakify/cache");
|
||||
const opts: FetchOptions = { cachePath, ...proxyOpts };
|
||||
const response = await fetch(url, opts);
|
||||
const filepath = pathJoin(dir, filename);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(filepath, response.body);
|
||||
return filepath;
|
||||
}
|
||||
export async function downloadAndUnzip(params: { url: string; destDirPath: string; pathOfDirToExtractInArchive?: string }) {
|
||||
const { url, destDirPath, pathOfDirToExtractInArchive } = params;
|
||||
|
||||
/**
|
||||
* @typedef
|
||||
* @type MultiError = Error & { cause: Error[] }
|
||||
*/
|
||||
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}`);
|
||||
|
||||
/**
|
||||
* Extract the archive `zipFile` into the directory `dir`. If `archiveDir` is given,
|
||||
* only that directory will be extracted, stripping the given path components.
|
||||
*
|
||||
* If dir does not exist, it will be created.
|
||||
*
|
||||
* If any archive file exists, it will be overwritten.
|
||||
*
|
||||
* Will unzip using all available nodejs worker threads.
|
||||
*
|
||||
* Will try to clean up extracted files on failure.
|
||||
*
|
||||
* If unpacking fails, will either throw an regular error, or
|
||||
* possibly an `MultiError`, which contains a `cause` field with
|
||||
* a number of root cause errors.
|
||||
*
|
||||
* Warning this method is not optimized for continuous reading of the zip
|
||||
* archive, but is a trade-off between simplicity and allowing extraction
|
||||
* of a single directory from the archive.
|
||||
*
|
||||
* @param zipFile the file to unzip
|
||||
* @param dir the target directory
|
||||
* @param archiveDir if given, unpack only files from this archive directory
|
||||
* @throws {MultiError} error
|
||||
* @returns Promise for a list of full file paths pointing to actually extracted files
|
||||
*/
|
||||
async function unzip(zipFile: string, dir: string, archiveDir?: string): Promise<string[]> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
const promises: Promise<string>[] = [];
|
||||
|
||||
// Iterate over all files in the zip, skip files which are not in archiveDir,
|
||||
// if given.
|
||||
for await (const record of iterateZipArchive(zipFile)) {
|
||||
const { path: recordPath, createReadStream: createRecordReadStream } = record;
|
||||
const filePath = pathJoin(dir, recordPath);
|
||||
const parent = pathDirname(filePath);
|
||||
if (archiveDir && !recordPath.startsWith(archiveDir)) continue;
|
||||
promises.push(
|
||||
new Promise<string>(async (resolve, reject) => {
|
||||
await mkdir(parent, { recursive: true });
|
||||
// Pull the file out of the archive, write it to the target directory
|
||||
const input = createRecordReadStream();
|
||||
const output = createWriteStream(filePath);
|
||||
output.setMaxListeners(Infinity);
|
||||
output.on("error", e => reject(Object.assign(e, { filePath })));
|
||||
output.on("finish", () => resolve(filePath));
|
||||
input.pipe(output);
|
||||
})
|
||||
);
|
||||
if (!(await exists(zipFilePath))) {
|
||||
const proxyOpts = await getNpmProxyConfig();
|
||||
const response = await fetch(url, proxyOpts);
|
||||
await mkdir(pathDirname(zipFilePath), { "recursive": true });
|
||||
/**
|
||||
* The correct way to fix this is to upgrade node-fetch beyond 3.2.5
|
||||
* (see https://github.com/node-fetch/node-fetch/issues/1295#issuecomment-1144061991.)
|
||||
* Unfortunately, octokit (a dependency of keycloakify) also uses node-fetch, and
|
||||
* does not support node-fetch 3.x. So we stick around with this band-aid until
|
||||
* octokit upgrades.
|
||||
*/
|
||||
response.body?.setMaxListeners(Number.MAX_VALUE);
|
||||
assert(typeof response.body !== "undefined" && response.body != null);
|
||||
await writeFile(zipFilePath, response.body);
|
||||
}
|
||||
|
||||
// Wait until _all_ files are either extracted or failed
|
||||
const results = await Promise.allSettled(promises);
|
||||
const success = results.filter(r => r.status === "fulfilled").map(r => (r as PromiseFulfilledResult<string>).value);
|
||||
const failure = results.filter(r => r.status === "rejected").map(r => (r as PromiseRejectedResult).reason);
|
||||
await unzip(zipFilePath, extractDirPath, pathOfDirToExtractInArchive);
|
||||
|
||||
// If any extraction failed, try to clean up, then throw a MultiError,
|
||||
// which has a `cause` field, containing a list of root cause errors.
|
||||
if (failure.length) {
|
||||
await Promise.all(success.map(path => unlink(path)));
|
||||
await Promise.all(failure.map(e => e && e.path && unlink(e.path as string)));
|
||||
const e = new Error("Failed to extract: " + failure.map(e => e.message).join(";"));
|
||||
(e as any).cause = failure;
|
||||
throw e;
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param file file to read
|
||||
* @param start first byte to read
|
||||
* @param end last byte to read
|
||||
* @returns Promise of a buffer of read bytes
|
||||
*/
|
||||
async function readFileChunk(file: string, start: number, end: number): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = createReadStream(file, { start, end });
|
||||
stream.setMaxListeners(Infinity);
|
||||
stream.on("error", e => reject(e));
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
stream.on("data", chunk => chunks.push(chunk as Buffer));
|
||||
transformCodebase({
|
||||
"srcDirPath": extractDirPath,
|
||||
"destDirPath": destDirPath
|
||||
});
|
||||
}
|
||||
|
||||
type ZipRecord = {
|
||||
path: string;
|
||||
createReadStream: () => Readable;
|
||||
compressionMethod: "deflate" | undefined;
|
||||
};
|
||||
|
||||
type ZipRecordGenerator = AsyncGenerator<ZipRecord, void, unknown>;
|
||||
|
||||
/**
|
||||
* Iterate over all records of a zipfile, and yield a ZipRecord.
|
||||
* Use `record.createReadStream()` to actually read the file.
|
||||
*
|
||||
* Warning this method will only work with single-disk zip files.
|
||||
* Warning this method may fail if the zip archive has an crazy amount
|
||||
* of files and the central directory is not fully contained within the
|
||||
* last 65k bytes of the zip file.
|
||||
*
|
||||
* @param zipFile
|
||||
* @returns AsyncGenerator which will yield ZipRecords
|
||||
*/
|
||||
async function* iterateZipArchive(zipFile: string): ZipRecordGenerator {
|
||||
// Need to know zip file size before we can do anything else
|
||||
const { size } = await stat(zipFile);
|
||||
const chunkSize = 65_535 + 22 + 1; // max comment size + end header size + wiggle
|
||||
// Read last ~65k bytes. Zip files have an comment up to 65_535 bytes at the very end,
|
||||
// before that comes the zip central directory end header.
|
||||
let chunk = await readFileChunk(zipFile, size - chunkSize, size);
|
||||
const unread = size - chunk.length;
|
||||
let i = chunk.length - 4;
|
||||
let found = false;
|
||||
// Find central directory end header, reading backwards from the end
|
||||
while (!found && i-- > 0) if (chunk[i] === 0x50 && chunk.readUInt32LE(i) === 0x06054b50) found = true;
|
||||
if (!found) throw new Error("Not a zip file");
|
||||
// This method will fail on a multi-disk zip, so bail early.
|
||||
if (chunk.readUInt16LE(i + 4) !== 0) throw new Error("Multi-disk zip not supported");
|
||||
let nFiles = chunk.readUint16LE(i + 10);
|
||||
// Get the position of the central directory
|
||||
const directorySize = chunk.readUint32LE(i + 12);
|
||||
const directoryOffset = chunk.readUint32LE(i + 16);
|
||||
if (directoryOffset === 0xffff_ffff) throw new Error("zip64 not supported");
|
||||
if (directoryOffset > size) throw new Error(`Central directory offset ${directoryOffset} is outside file`);
|
||||
i = directoryOffset - unread;
|
||||
// If i < 0, it means that the central directory is not contained within `chunk`
|
||||
if (i < 0) {
|
||||
chunk = await readFileChunk(zipFile, directoryOffset, directoryOffset + directorySize);
|
||||
i = 0;
|
||||
}
|
||||
// Now iterate the central directory records, yield an `ZipRecord` for every entry
|
||||
while (nFiles-- > 0) {
|
||||
// Check for marker bytes
|
||||
if (chunk.readUInt32LE(i) !== 0x02014b50) throw new Error("No central directory record at position " + (unread + i));
|
||||
const compressionMethod = ({ 8: "deflate" } as const)[chunk.readUint16LE(i + 10)];
|
||||
const compressedFileSize = chunk.readUint32LE(i + 20);
|
||||
const filenameLength = chunk.readUint16LE(i + 28);
|
||||
const extraLength = chunk.readUint16LE(i + 30);
|
||||
const commentLength = chunk.readUint16LE(i + 32);
|
||||
// Start of the actual content byte stream is after the 'local' record header,
|
||||
// which is 30 bytes long plus filename and extra field
|
||||
const start = chunk.readUint32LE(i + 42) + 30 + filenameLength + extraLength;
|
||||
const end = start + compressedFileSize;
|
||||
const filename = chunk.slice(i + 46, i + 46 + filenameLength).toString("utf-8");
|
||||
const createRecordReadStream = () => {
|
||||
const input = createReadStream(zipFile, { start, end });
|
||||
if (compressionMethod === "deflate") {
|
||||
const inflate = createInflateRaw();
|
||||
input.pipe(inflate);
|
||||
return inflate;
|
||||
}
|
||||
return input;
|
||||
};
|
||||
if (end > start) yield { path: filename, createReadStream: createRecordReadStream, compressionMethod };
|
||||
// advance pointer to next central directory entry
|
||||
i += 46 + filenameLength + extraLength + commentLength;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadAndUnzip({
|
||||
url,
|
||||
destDirPath,
|
||||
pathOfDirToExtractInArchive,
|
||||
cacheDirPath
|
||||
}: {
|
||||
isSilent: boolean;
|
||||
url: string;
|
||||
destDirPath: string;
|
||||
pathOfDirToExtractInArchive?: string;
|
||||
cacheDirPath: string;
|
||||
}) {
|
||||
const downloadHash = hash(JSON.stringify({ url, pathOfDirToExtractInArchive })).substring(0, 15);
|
||||
const extractDirPath = pathJoin(cacheDirPath, `_${downloadHash}`);
|
||||
|
||||
const filename = pathBasename(url);
|
||||
const zipFilepath = await download(url, cacheDirPath, filename);
|
||||
const zipMtime = (await stat(zipFilepath)).mtimeMs;
|
||||
const unzipMtime = (await maybeStat(extractDirPath))?.mtimeMs;
|
||||
|
||||
if (!unzipMtime || zipMtime > unzipMtime) await unzip(zipFilepath, extractDirPath, pathOfDirToExtractInArchive);
|
||||
|
||||
const srcDirPath = pathOfDirToExtractInArchive === undefined ? extractDirPath : pathJoin(extractDirPath, pathOfDirToExtractInArchive);
|
||||
transformCodebase({ srcDirPath, destDirPath });
|
||||
}
|
||||
|
@ -3,12 +3,9 @@ import { dirname, relative, sep } from "path";
|
||||
import { createWriteStream } from "fs";
|
||||
|
||||
import walk from "./walk";
|
||||
import type { ZipSource } from "./zip";
|
||||
import zip from "./zip";
|
||||
import zip, { type ZipSource } from "./zip";
|
||||
import { mkdir } from "fs/promises";
|
||||
|
||||
/** Trim leading whitespace from every line */
|
||||
const trimIndent = (s: string) => s.replace(/(\n)\s+/g, "$1");
|
||||
import trimIndent from "./trimIndent";
|
||||
|
||||
type JarArgs = {
|
||||
rootPath: string;
|
||||
@ -26,28 +23,23 @@ type JarArgs = {
|
||||
export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) {
|
||||
const manifest: ZipSource = {
|
||||
path: "META-INF/MANIFEST.MF",
|
||||
data: Buffer.from(
|
||||
trimIndent(
|
||||
`Manifest-Version: 1.0
|
||||
Archiver-Version: Plexus Archiver
|
||||
Created-By: Keycloakify
|
||||
Built-By: unknown
|
||||
Build-Jdk: 19.0.0`
|
||||
)
|
||||
)
|
||||
data: Buffer.from(trimIndent`
|
||||
Manifest-Version: 1.0
|
||||
Archiver-Version: Plexus Archiver
|
||||
Created-By: Keycloakify
|
||||
Built-By: unknown
|
||||
Build-Jdk: 19.0.0
|
||||
`)
|
||||
};
|
||||
|
||||
const pomProps: ZipSource = {
|
||||
path: `META-INF/maven/${groupId}/${artifactId}/pom.properties`,
|
||||
data: Buffer.from(
|
||||
trimIndent(
|
||||
`# Generated by keycloakify
|
||||
# ${new Date()}
|
||||
artifactId=${artifactId}
|
||||
groupId=${groupId}
|
||||
version=${version}`
|
||||
)
|
||||
)
|
||||
data: Buffer.from(trimIndent`# Generated by keycloakify
|
||||
# ${new Date().toString()}
|
||||
artifactId=${artifactId}
|
||||
groupId=${groupId}
|
||||
version=${version}
|
||||
`)
|
||||
};
|
||||
|
||||
/**
|
||||
@ -98,5 +90,5 @@ if (require.main === module) {
|
||||
groupId: process.env.GROUP_ID ?? "group",
|
||||
version: process.env.VERSION ?? "1.0.0"
|
||||
});
|
||||
main().catch(e => console.error(e));
|
||||
main();
|
||||
}
|
||||
|
11
src/bin/tools/partitionPromiseSettledResults.ts
Normal file
11
src/bin/tools/partitionPromiseSettledResults.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export type PromiseSettledAndPartitioned<T> = [T[], any[]];
|
||||
|
||||
export function partitionPromiseSettledResults<T>() {
|
||||
return [
|
||||
([successes, failures]: PromiseSettledAndPartitioned<T>, item: PromiseSettledResult<T>) =>
|
||||
item.status === "rejected"
|
||||
? ([successes, [item.reason, ...failures]] as PromiseSettledAndPartitioned<T>)
|
||||
: ([[item.value, ...successes], failures] as PromiseSettledAndPartitioned<T>),
|
||||
[[], []] as PromiseSettledAndPartitioned<T>
|
||||
] as const;
|
||||
}
|
@ -7,6 +7,8 @@ export default function tee(input: Readable) {
|
||||
let aFull = false;
|
||||
let bFull = false;
|
||||
|
||||
a.setMaxListeners(Infinity);
|
||||
|
||||
a.on("drain", () => {
|
||||
aFull = false;
|
||||
if (!aFull && !bFull) input.resume();
|
||||
|
51
src/bin/tools/trimIndent.ts
Normal file
51
src/bin/tools/trimIndent.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Concatenate the string fragments and interpolated values
|
||||
* to get a single string.
|
||||
*/
|
||||
function populateTemplate(strings: TemplateStringsArray, ...args: any[]) {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
let lastStringLineLength = 0;
|
||||
if (strings[i]) {
|
||||
chunks.push(strings[i]);
|
||||
// remember last indent of the string portion
|
||||
lastStringLineLength = strings[i].split("\n").at(-1)?.length ?? 0;
|
||||
}
|
||||
if (args[i]) {
|
||||
// if the interpolation value has newlines, indent the interpolation values
|
||||
// using the last known string indent
|
||||
chunks.push(args[i].replace(/([\r?\n])/g, "$1" + " ".repeat(lastStringLineLength)));
|
||||
}
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
function trimIndentPrivate(removeEmptyLeadingAndTrailingLines: boolean, strings: TemplateStringsArray, ...args: any[]) {
|
||||
// Remove initial and final newlines
|
||||
let string = populateTemplate(strings, ...args);
|
||||
if (removeEmptyLeadingAndTrailingLines) string = string.replace(/^[\r\n]/, "").replace(/[^\S\r\n]*[\r\n]$/, "");
|
||||
const dents = string.match(/^([ \t])+/gm)?.map(s => s.length) ?? [];
|
||||
// No dents? no change required
|
||||
if (!dents || dents.length == 0) return string;
|
||||
const minDent = Math.min(...dents);
|
||||
// The min indentation is 0, no change needed
|
||||
if (!minDent) return string;
|
||||
const dedented = string.replace(new RegExp(`^${" ".repeat(minDent)}`, "gm"), "");
|
||||
return dedented;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift all lines left by the *smallest* indentation level,
|
||||
* and remove initial newline and all trailing spaces.
|
||||
*/
|
||||
export default function trimIndent(strings: TemplateStringsArray, ...args: any[]) {
|
||||
return trimIndentPrivate(true, strings, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift all lines left by the *smallest* indentation level,
|
||||
* and _keep_ initial newline and all trailing spaces.
|
||||
*/
|
||||
trimIndent.keepLeadingAndTrailingNewlines = function (strings: TemplateStringsArray, ...args: any[]) {
|
||||
return trimIndentPrivate(false, strings, ...args);
|
||||
};
|
92
src/bin/tools/unzip.ts
Normal file
92
src/bin/tools/unzip.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import fsp from "node:fs/promises";
|
||||
import fs from "fs";
|
||||
import path from "node:path";
|
||||
import yauzl from "yauzl";
|
||||
import stream from "node:stream";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const pipeline = promisify(stream.pipeline);
|
||||
|
||||
async function pathExists(path: string) {
|
||||
try {
|
||||
await fsp.stat(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as { code: string }).code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unzip(file: string, targetFolder: string, unzipSubPath?: string) {
|
||||
// add trailing slash to unzipSubPath and targetFolder
|
||||
if (unzipSubPath && (!unzipSubPath.endsWith("/") || !unzipSubPath.endsWith("\\"))) {
|
||||
unzipSubPath += "/";
|
||||
}
|
||||
|
||||
if (!targetFolder.endsWith("/") || !targetFolder.endsWith("\\")) {
|
||||
targetFolder += "/";
|
||||
}
|
||||
if (!fs.existsSync(targetFolder)) {
|
||||
fs.mkdirSync(targetFolder, { recursive: true });
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
yauzl.open(file, { lazyEntries: true }, async (err, zipfile) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
zipfile.readEntry();
|
||||
|
||||
zipfile.on("entry", async entry => {
|
||||
if (unzipSubPath) {
|
||||
// Skip files outside of the unzipSubPath
|
||||
if (!entry.fileName.startsWith(unzipSubPath)) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the unzipSubPath from the file name
|
||||
entry.fileName = entry.fileName.substring(unzipSubPath.length);
|
||||
}
|
||||
|
||||
const target = path.join(targetFolder, entry.fileName);
|
||||
|
||||
// Directory file names end with '/'.
|
||||
// Note that entries for directories themselves are optional.
|
||||
// An entry's fileName implicitly requires its parent directories to exist.
|
||||
if (/[\/\\]$/.test(target)) {
|
||||
await fsp.mkdir(target, { recursive: true });
|
||||
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip existing files
|
||||
if (await pathExists(target)) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
zipfile.openReadStream(entry, async (err, readStream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
await pipeline(readStream, fs.createWriteStream(target));
|
||||
|
||||
zipfile.readEntry();
|
||||
});
|
||||
});
|
||||
|
||||
zipfile.once("end", function () {
|
||||
zipfile.close();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -25,6 +25,7 @@ const LoginConfigTotp = lazy(() => import("keycloakify/login/pages/LoginConfigTo
|
||||
const LogoutConfirm = lazy(() => import("keycloakify/login/pages/LogoutConfirm"));
|
||||
const UpdateUserProfile = lazy(() => import("keycloakify/login/pages/UpdateUserProfile"));
|
||||
const IdpReviewUserProfile = lazy(() => import("keycloakify/login/pages/IdpReviewUserProfile"));
|
||||
const UpdateEmail = lazy(() => import("keycloakify/login/pages/UpdateEmail"));
|
||||
|
||||
export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
const { kcContext, ...rest } = props;
|
||||
@ -75,6 +76,8 @@ export default function Fallback(props: PageProps<KcContext, I18n>) {
|
||||
return <UpdateUserProfile kcContext={kcContext} {...rest} />;
|
||||
case "idp-review-user-profile.ftl":
|
||||
return <IdpReviewUserProfile kcContext={kcContext} {...rest} />;
|
||||
case "update-email.ftl":
|
||||
return <UpdateEmail kcContext={kcContext} {...rest} />;
|
||||
}
|
||||
assert<Equals<typeof kcContext, never>>(false);
|
||||
})()}
|
||||
|
@ -30,7 +30,8 @@ export type KcContext =
|
||||
| KcContext.LoginConfigTotp
|
||||
| KcContext.LogoutConfirm
|
||||
| KcContext.UpdateUserProfile
|
||||
| KcContext.IdpReviewUserProfile;
|
||||
| KcContext.IdpReviewUserProfile
|
||||
| KcContext.UpdateEmail;
|
||||
|
||||
export declare namespace KcContext {
|
||||
export type Common = {
|
||||
@ -101,7 +102,8 @@ export declare namespace KcContext {
|
||||
registrationDisabled: boolean;
|
||||
login: {
|
||||
username?: string;
|
||||
rememberMe?: boolean;
|
||||
rememberMe?: string;
|
||||
password?: string;
|
||||
};
|
||||
usernameEditDisabled: boolean;
|
||||
social: {
|
||||
@ -182,6 +184,9 @@ export declare namespace KcContext {
|
||||
realm: {
|
||||
loginWithEmailAllowed: boolean;
|
||||
};
|
||||
url: {
|
||||
loginResetCredentialsUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type LoginVerifyEmail = Common & {
|
||||
@ -219,7 +224,7 @@ export declare namespace KcContext {
|
||||
registrationDisabled: boolean;
|
||||
login: {
|
||||
username?: string;
|
||||
rememberMe?: boolean;
|
||||
rememberMe?: string;
|
||||
};
|
||||
usernameHidden?: boolean;
|
||||
social: {
|
||||
@ -377,6 +382,13 @@ export declare namespace KcContext {
|
||||
attributesByName: Record<string, Attribute>;
|
||||
};
|
||||
};
|
||||
|
||||
export type UpdateEmail = Common & {
|
||||
pageId: "update-email.ftl";
|
||||
email: {
|
||||
value?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type Attribute = {
|
||||
|
@ -260,9 +260,7 @@ export const kcContextMocks: KcContext[] = [
|
||||
"displayInfo": true
|
||||
},
|
||||
"usernameEditDisabled": false,
|
||||
"login": {
|
||||
"rememberMe": false
|
||||
},
|
||||
"login": {},
|
||||
"registrationDisabled": false
|
||||
}),
|
||||
...(() => {
|
||||
@ -331,7 +329,8 @@ export const kcContextMocks: KcContext[] = [
|
||||
"realm": {
|
||||
...kcContextCommonMock.realm,
|
||||
"loginWithEmailAllowed": false
|
||||
}
|
||||
},
|
||||
url: loginUrl
|
||||
}),
|
||||
id<KcContext.LoginVerifyEmail>({
|
||||
...kcContextCommonMock,
|
||||
@ -376,9 +375,7 @@ export const kcContextMocks: KcContext[] = [
|
||||
"displayInfo": true
|
||||
},
|
||||
"usernameHidden": false,
|
||||
"login": {
|
||||
"rememberMe": false
|
||||
},
|
||||
"login": {},
|
||||
"registrationDisabled": false
|
||||
}),
|
||||
id<KcContext.LoginPassword>({
|
||||
@ -494,5 +491,12 @@ export const kcContextMocks: KcContext[] = [
|
||||
attributes,
|
||||
attributesByName
|
||||
}
|
||||
}),
|
||||
id<KcContext.UpdateEmail>({
|
||||
...kcContextCommonMock,
|
||||
"pageId": "update-email.ftl",
|
||||
"email": {
|
||||
value: "email@example.com"
|
||||
}
|
||||
})
|
||||
];
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import { UserProfileFormFields } from "keycloakify/login/pages/shared/UserProfileCommons";
|
||||
import { UserProfileFormFields } from "keycloakify/login/pages/shared/UserProfileFormFields";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
|
@ -124,7 +124,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
|
||||
id="rememberMe"
|
||||
name="rememberMe"
|
||||
type="checkbox"
|
||||
{...(login.rememberMe
|
||||
{...(login.rememberMe === "on"
|
||||
? {
|
||||
"checked": true
|
||||
}
|
||||
|
@ -109,7 +109,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
|
||||
id="rememberMe"
|
||||
name="rememberMe"
|
||||
type="checkbox"
|
||||
{...(login.rememberMe
|
||||
{...(login.rememberMe === "on"
|
||||
? {
|
||||
"checked": true
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import { UserProfileFormFields } from "./shared/UserProfileCommons";
|
||||
import { UserProfileFormFields } from "./shared/UserProfileFormFields";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
|
88
src/login/pages/UpdateEmail.tsx
Normal file
88
src/login/pages/UpdateEmail.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export default function UpdateEmail(props: PageProps<Extract<KcContext, { pageId: "update-email.ftl" }>, I18n>) {
|
||||
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
|
||||
|
||||
const { getClassName } = useGetClassName({
|
||||
doUseDefaultCss,
|
||||
classes
|
||||
});
|
||||
|
||||
const { msg, msgStr } = i18n;
|
||||
|
||||
const { url, messagesPerField, isAppInitiatedAction, email } = kcContext;
|
||||
|
||||
return (
|
||||
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("updateEmailTitle")}>
|
||||
<form id="kc-update-email-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
|
||||
<div
|
||||
className={clsx(getClassName("kcFormGroupClass"), messagesPerField.printIfExists("email", getClassName("kcFormGroupErrorClass")))}
|
||||
>
|
||||
<div className={getClassName("kcLabelWrapperClass")}>
|
||||
<label htmlFor="email" className={getClassName("kcLabelClass")}>
|
||||
{msg("email")}
|
||||
</label>
|
||||
</div>
|
||||
<div className={getClassName("kcInputWrapperClass")}>
|
||||
<input
|
||||
type="text"
|
||||
id="email"
|
||||
name="email"
|
||||
defaultValue={email.value ?? ""}
|
||||
className={getClassName("kcInputClass")}
|
||||
aria-invalid={messagesPerField.existsError("email")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={getClassName("kcFormGroupClass")}>
|
||||
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
|
||||
<div className={getClassName("kcFormOptionsWrapperClass")}></div>
|
||||
</div>
|
||||
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
|
||||
{isAppInitiatedAction ? (
|
||||
<>
|
||||
<input
|
||||
className={clsx(
|
||||
getClassName("kcButtonClass"),
|
||||
getClassName("kcButtonPrimaryClass"),
|
||||
getClassName("kcButtonLargeClass")
|
||||
)}
|
||||
type="submit"
|
||||
defaultValue={msgStr("doSubmit")}
|
||||
/>
|
||||
<button
|
||||
className={clsx(
|
||||
getClassName("kcButtonClass"),
|
||||
getClassName("kcButtonDefaultClass"),
|
||||
getClassName("kcButtonLargeClass")
|
||||
)}
|
||||
type="submit"
|
||||
name="cancel-aia"
|
||||
value="true"
|
||||
>
|
||||
{msg("doCancel")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<input
|
||||
className={clsx(
|
||||
getClassName("kcButtonClass"),
|
||||
getClassName("kcButtonPrimaryClass"),
|
||||
getClassName("kcButtonBlockClass"),
|
||||
getClassName("kcButtonLargeClass")
|
||||
)}
|
||||
type="submit"
|
||||
defaultValue={msgStr("doSubmit")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Template>
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import { UserProfileFormFields } from "keycloakify/login/pages/shared/UserProfileCommons";
|
||||
import { UserProfileFormFields } from "keycloakify/login/pages/shared/UserProfileFormFields";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
|
||||
import type { KcContext } from "../kcContext";
|
||||
|
@ -1,20 +0,0 @@
|
||||
import { join as pathJoin } from "path";
|
||||
import { generateKeycloakThemeResources } from "keycloakify/bin/keycloakify/generateKeycloakThemeResources";
|
||||
import { setupSampleReactProject, sampleReactProjectDirPath } from "./setupSampleReactProject";
|
||||
|
||||
setupSampleReactProject();
|
||||
|
||||
generateKeycloakThemeResources({
|
||||
"reactAppBuildDirPath": pathJoin(sampleReactProjectDirPath, "build"),
|
||||
"keycloakThemeBuildingDirPath": pathJoin(sampleReactProjectDirPath, "build_keycloak_theme"),
|
||||
"keycloakThemeEmailDirPath": pathJoin(sampleReactProjectDirPath, "keycloak_email"),
|
||||
"keycloakVersion": "11.0.3",
|
||||
"buildOptions": {
|
||||
"themeName": "keycloakify-demo-app",
|
||||
"extraLoginPages": ["my-custom-page.ftl"],
|
||||
"extraThemeProperties": ["env=test"],
|
||||
"isStandalone": true,
|
||||
"urlPathname": "/keycloakify-demo-app/",
|
||||
"isSilent": false
|
||||
}
|
||||
});
|
@ -1 +0,0 @@
|
||||
import "./replaceImportFromStatic";
|
26
test/bin/jar.spec.ts
Normal file
26
test/bin/jar.spec.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import jar from "keycloakify/bin/tools/jar";
|
||||
import { it, describe, vi } from "vitest";
|
||||
|
||||
vi.mock("fs", () => ({ promises: { mkdir: () => {} }, createWriteStream: () => {} }));
|
||||
vi.mock("stream", async () => {
|
||||
const readableMock = () => {
|
||||
const mockDecorators = {
|
||||
on: () => mockDecorators,
|
||||
pipe: () => mockDecorators
|
||||
};
|
||||
return {
|
||||
from: () => mockDecorators
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
// @ts-ignore
|
||||
...(await vi.importActual("stream")),
|
||||
Readable: readableMock()
|
||||
};
|
||||
});
|
||||
describe("jar", () => {
|
||||
it("creates jar artifacts without error", () => {
|
||||
jar({ artifactId: "artifactId", groupId: "groupId", rootPath: "rootPath", targetPath: "targetPath", version: "1.0.0" });
|
||||
});
|
||||
});
|
@ -1,26 +0,0 @@
|
||||
import "./replaceImportFromStatic";
|
||||
import { setupSampleReactProject, sampleReactProjectDirPath } from "./setupSampleReactProject";
|
||||
import * as st from "scripting-tools";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { getProjectRoot } from "keycloakify/bin/tools/getProjectRoot.js";
|
||||
|
||||
(async () => {
|
||||
fs.rmSync(sampleReactProjectDirPath, { "recursive": true });
|
||||
|
||||
await setupSampleReactProject();
|
||||
|
||||
const binDirPath = pathJoin(getProjectRoot(), "dist_test", "src", "bin");
|
||||
|
||||
fs.mkdirSync(pathJoin(sampleReactProjectDirPath, "src", "keycloak-theme"), { "recursive": true });
|
||||
|
||||
st.execSyncTrace(`node ${pathJoin(binDirPath, "initialize-email-theme")}`, { "cwd": sampleReactProjectDirPath });
|
||||
|
||||
st.execSyncTrace(`node ${pathJoin(binDirPath, "download-builtin-keycloak-theme")}`, { "cwd": sampleReactProjectDirPath });
|
||||
|
||||
st.execSyncTrace(
|
||||
//`node ${pathJoin(binDirPath, "keycloakify")} --external-assets`,
|
||||
`node ${pathJoin(binDirPath, "keycloakify")}`,
|
||||
{ "cwd": sampleReactProjectDirPath }
|
||||
);
|
||||
})();
|
@ -1,11 +1,12 @@
|
||||
import { replaceImportsFromStaticInJsCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsFromStaticInJsCode";
|
||||
import { generateCssCodeToDefineGlobals, replaceImportsInCssCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsInCssCode";
|
||||
import { replaceImportsInInlineCssCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsInInlineCssCode";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { same } from "evt/tools/inDepth/same";
|
||||
import { assetIsSameCode } from "../tools/assertIsSameCode";
|
||||
import { expect, it, describe } from "vitest";
|
||||
|
||||
{
|
||||
import { isSameCode } from "../tools/isSameCode";
|
||||
|
||||
describe("bin/js-transforms", () => {
|
||||
const jsCodeUntransformed = `
|
||||
function f() {
|
||||
return a.p+"static/js/" + ({}[e] || e) + "." + {
|
||||
@ -32,8 +33,7 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
|
||||
}[e]+".chunk.css"
|
||||
}
|
||||
`;
|
||||
|
||||
{
|
||||
it("transforms standalone code properly", () => {
|
||||
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
|
||||
"jsCode": jsCodeUntransformed,
|
||||
"buildOptions": {
|
||||
@ -89,10 +89,9 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
|
||||
|
||||
`;
|
||||
|
||||
assetIsSameCode(fixedJsCode, fixedJsCodeExpected);
|
||||
}
|
||||
|
||||
{
|
||||
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
|
||||
});
|
||||
it("transforms external app code properly", () => {
|
||||
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
|
||||
"jsCode": jsCodeUntransformed,
|
||||
"buildOptions": {
|
||||
@ -150,126 +149,128 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
|
||||
}
|
||||
`;
|
||||
|
||||
assetIsSameCode(fixedJsCode, fixedJsCodeExpected);
|
||||
}
|
||||
}
|
||||
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
{
|
||||
const { fixedCssCode, cssGlobalsToDefine } = replaceImportsInCssCode({
|
||||
"cssCode": `
|
||||
describe("bin/css-transforms", () => {
|
||||
it("transforms absolute urls to css globals properly with no urlPathname", () => {
|
||||
const { fixedCssCode, cssGlobalsToDefine } = replaceImportsInCssCode({
|
||||
"cssCode": `
|
||||
.my-div {
|
||||
background: url(/logo192.png) no-repeat center center;
|
||||
}
|
||||
|
||||
.my-div2 {
|
||||
background: url(/logo192.png) no-repeat center center;
|
||||
}
|
||||
|
||||
.my-div {
|
||||
background-image: url(/static/media/something.svg);
|
||||
}
|
||||
`
|
||||
});
|
||||
|
||||
const fixedCssCodeExpected = `
|
||||
.my-div {
|
||||
background: url(/logo192.png) no-repeat center center;
|
||||
background: var(--url1f9ef5a892c104c);
|
||||
}
|
||||
|
||||
|
||||
.my-div2 {
|
||||
background: url(/logo192.png) no-repeat center center;
|
||||
background: var(--url1f9ef5a892c104c);
|
||||
}
|
||||
|
||||
|
||||
.my-div {
|
||||
background-image: url(/static/media/something.svg);
|
||||
background-image: var(--urldd75cab58377c19);
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
|
||||
|
||||
const cssGlobalsToDefineExpected = {
|
||||
"url1f9ef5a892c104c": "url(/logo192.png) no-repeat center center",
|
||||
"urldd75cab58377c19": "url(/static/media/something.svg)"
|
||||
};
|
||||
|
||||
expect(same(cssGlobalsToDefine, cssGlobalsToDefineExpected)).toBe(true);
|
||||
|
||||
const { cssCodeToPrependInHead } = generateCssCodeToDefineGlobals({
|
||||
cssGlobalsToDefine,
|
||||
"buildOptions": {
|
||||
"urlPathname": undefined
|
||||
}
|
||||
});
|
||||
|
||||
const cssCodeToPrependInHeadExpected = `
|
||||
:root {
|
||||
--url1f9ef5a892c104c: url(\${url.resourcesPath}/build/logo192.png) no-repeat center center;
|
||||
--urldd75cab58377c19: url(\${url.resourcesPath}/build/static/media/something.svg);
|
||||
}
|
||||
`;
|
||||
|
||||
expect(isSameCode(cssCodeToPrependInHead, cssCodeToPrependInHeadExpected)).toBe(true);
|
||||
});
|
||||
it("transforms absolute urls to css globals properly with custom urlPathname", () => {
|
||||
const { fixedCssCode, cssGlobalsToDefine } = replaceImportsInCssCode({
|
||||
"cssCode": `
|
||||
.my-div {
|
||||
background: url(/x/y/z/logo192.png) no-repeat center center;
|
||||
}
|
||||
|
||||
.my-div2 {
|
||||
background: url(/x/y/z/logo192.png) no-repeat center center;
|
||||
}
|
||||
|
||||
.my-div {
|
||||
background-image: url(/x/y/z/static/media/something.svg);
|
||||
}
|
||||
`
|
||||
});
|
||||
|
||||
const fixedCssCodeExpected = `
|
||||
.my-div {
|
||||
background: var(--url1f9ef5a892c104c);
|
||||
}
|
||||
|
||||
.my-div2 {
|
||||
background: var(--url1f9ef5a892c104c);
|
||||
}
|
||||
|
||||
.my-div {
|
||||
background-image: var(--urldd75cab58377c19);
|
||||
}
|
||||
`;
|
||||
|
||||
assetIsSameCode(fixedCssCode, fixedCssCodeExpected);
|
||||
|
||||
const cssGlobalsToDefineExpected = {
|
||||
"url1f9ef5a892c104c": "url(/logo192.png) no-repeat center center",
|
||||
"urldd75cab58377c19": "url(/static/media/something.svg)"
|
||||
};
|
||||
|
||||
assert(same(cssGlobalsToDefine, cssGlobalsToDefineExpected));
|
||||
|
||||
const { cssCodeToPrependInHead } = generateCssCodeToDefineGlobals({
|
||||
cssGlobalsToDefine,
|
||||
"buildOptions": {
|
||||
"urlPathname": undefined
|
||||
}
|
||||
});
|
||||
|
||||
const cssCodeToPrependInHeadExpected = `
|
||||
:root {
|
||||
--url1f9ef5a892c104c: url(\${url.resourcesPath}/build/logo192.png) no-repeat center center;
|
||||
--urldd75cab58377c19: url(\${url.resourcesPath}/build/static/media/something.svg);
|
||||
}
|
||||
`;
|
||||
|
||||
assetIsSameCode(cssCodeToPrependInHead, cssCodeToPrependInHeadExpected);
|
||||
}
|
||||
|
||||
{
|
||||
const { fixedCssCode, cssGlobalsToDefine } = replaceImportsInCssCode({
|
||||
"cssCode": `
|
||||
const fixedCssCodeExpected = `
|
||||
.my-div {
|
||||
background: url(/x/y/z/logo192.png) no-repeat center center;
|
||||
background: var(--urlf8277cddaa2be78);
|
||||
}
|
||||
|
||||
|
||||
.my-div2 {
|
||||
background: url(/x/y/z/logo192.png) no-repeat center center;
|
||||
background: var(--urlf8277cddaa2be78);
|
||||
}
|
||||
|
||||
|
||||
.my-div {
|
||||
background-image: url(/x/y/z/static/media/something.svg);
|
||||
background-image: var(--url8bdc0887b97ac9a);
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
|
||||
|
||||
const cssGlobalsToDefineExpected = {
|
||||
"urlf8277cddaa2be78": "url(/x/y/z/logo192.png) no-repeat center center",
|
||||
"url8bdc0887b97ac9a": "url(/x/y/z/static/media/something.svg)"
|
||||
};
|
||||
|
||||
expect(same(cssGlobalsToDefine, cssGlobalsToDefineExpected)).toBe(true);
|
||||
|
||||
const { cssCodeToPrependInHead } = generateCssCodeToDefineGlobals({
|
||||
cssGlobalsToDefine,
|
||||
"buildOptions": {
|
||||
"urlPathname": "/x/y/z/"
|
||||
}
|
||||
});
|
||||
|
||||
const cssCodeToPrependInHeadExpected = `
|
||||
:root {
|
||||
--urlf8277cddaa2be78: url(\${url.resourcesPath}/build/logo192.png) no-repeat center center;
|
||||
--url8bdc0887b97ac9a: url(\${url.resourcesPath}/build/static/media/something.svg);
|
||||
}
|
||||
`;
|
||||
|
||||
expect(isSameCode(cssCodeToPrependInHead, cssCodeToPrependInHeadExpected)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const fixedCssCodeExpected = `
|
||||
.my-div {
|
||||
background: var(--urlf8277cddaa2be78);
|
||||
}
|
||||
|
||||
.my-div2 {
|
||||
background: var(--urlf8277cddaa2be78);
|
||||
}
|
||||
|
||||
.my-div {
|
||||
background-image: var(--url8bdc0887b97ac9a);
|
||||
}
|
||||
`;
|
||||
|
||||
assetIsSameCode(fixedCssCode, fixedCssCodeExpected);
|
||||
|
||||
const cssGlobalsToDefineExpected = {
|
||||
"urlf8277cddaa2be78": "url(/x/y/z/logo192.png) no-repeat center center",
|
||||
"url8bdc0887b97ac9a": "url(/x/y/z/static/media/something.svg)"
|
||||
};
|
||||
|
||||
assert(same(cssGlobalsToDefine, cssGlobalsToDefineExpected));
|
||||
|
||||
const { cssCodeToPrependInHead } = generateCssCodeToDefineGlobals({
|
||||
cssGlobalsToDefine,
|
||||
"buildOptions": {
|
||||
"urlPathname": "/x/y/z/"
|
||||
}
|
||||
});
|
||||
|
||||
const cssCodeToPrependInHeadExpected = `
|
||||
:root {
|
||||
--urlf8277cddaa2be78: url(\${url.resourcesPath}/build/logo192.png) no-repeat center center;
|
||||
--url8bdc0887b97ac9a: url(\${url.resourcesPath}/build/static/media/something.svg);
|
||||
}
|
||||
`;
|
||||
|
||||
assetIsSameCode(cssCodeToPrependInHead, cssCodeToPrependInHeadExpected);
|
||||
}
|
||||
|
||||
{
|
||||
const cssCode = `
|
||||
describe("bin/css-inline-transforms", () => {
|
||||
describe("no url pathName", () => {
|
||||
const cssCode = `
|
||||
@font-face {
|
||||
font-family: "Work Sans";
|
||||
font-style: normal;
|
||||
@ -299,17 +300,16 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
|
||||
src: url("/fonts/WorkSans/worksans-bold-webfont.woff2") format("woff2");
|
||||
}
|
||||
`;
|
||||
it("transforms css for standalone app properly", () => {
|
||||
const { fixedCssCode } = replaceImportsInInlineCssCode({
|
||||
cssCode,
|
||||
"buildOptions": {
|
||||
"isStandalone": true,
|
||||
"urlPathname": undefined
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
const { fixedCssCode } = replaceImportsInInlineCssCode({
|
||||
cssCode,
|
||||
"buildOptions": {
|
||||
"isStandalone": true,
|
||||
"urlPathname": undefined
|
||||
}
|
||||
});
|
||||
|
||||
const fixedCssCodeExpected = `
|
||||
const fixedCssCodeExpected = `
|
||||
@font-face {
|
||||
font-family: "Work Sans";
|
||||
font-style: normal;
|
||||
@ -344,20 +344,19 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
|
||||
}
|
||||
`;
|
||||
|
||||
assetIsSameCode(fixedCssCode, fixedCssCodeExpected);
|
||||
}
|
||||
|
||||
{
|
||||
const { fixedCssCode } = replaceImportsInInlineCssCode({
|
||||
cssCode,
|
||||
"buildOptions": {
|
||||
"isStandalone": false,
|
||||
"urlOrigin": "https://demo-app.keycloakify.dev",
|
||||
"urlPathname": undefined
|
||||
}
|
||||
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 = `
|
||||
const fixedCssCodeExpected = `
|
||||
@font-face {
|
||||
font-family: "Work Sans";
|
||||
font-style: normal;
|
||||
@ -392,12 +391,12 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
|
||||
}
|
||||
`;
|
||||
|
||||
assetIsSameCode(fixedCssCode, fixedCssCodeExpected);
|
||||
}
|
||||
}
|
||||
expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
{
|
||||
const cssCode = `
|
||||
describe("with url pathName", () => {
|
||||
const cssCode = `
|
||||
@font-face {
|
||||
font-family: "Work Sans";
|
||||
font-style: normal;
|
||||
@ -427,101 +426,98 @@ import { assetIsSameCode } from "../tools/assertIsSameCode";
|
||||
src: url("/x/y/z/fonts/WorkSans/worksans-bold-webfont.woff2") format("woff2");
|
||||
}
|
||||
`;
|
||||
it("transforms css for standalone app properly", () => {
|
||||
const { fixedCssCode } = replaceImportsInInlineCssCode({
|
||||
cssCode,
|
||||
"buildOptions": {
|
||||
"isStandalone": true,
|
||||
"urlPathname": "/x/y/z/"
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
const { fixedCssCode } = replaceImportsInInlineCssCode({
|
||||
cssCode,
|
||||
"buildOptions": {
|
||||
"isStandalone": true,
|
||||
"urlPathname": "/x/y/z/"
|
||||
}
|
||||
const fixedCssCodeExpected = `
|
||||
@font-face {
|
||||
font-family: "Work Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(\${url.resourcesPath}/build/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(\${url.resourcesPath}/build/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(\${url.resourcesPath}/build/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(\${url.resourcesPath}/build/fonts/WorkSans/worksans-bold-webfont.woff2)
|
||||
format("woff2");
|
||||
}
|
||||
`;
|
||||
|
||||
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(\${url.resourcesPath}/build/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(\${url.resourcesPath}/build/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(\${url.resourcesPath}/build/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(\${url.resourcesPath}/build/fonts/WorkSans/worksans-bold-webfont.woff2)
|
||||
format("woff2");
|
||||
}
|
||||
`;
|
||||
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");
|
||||
}
|
||||
`;
|
||||
|
||||
assetIsSameCode(fixedCssCode, fixedCssCodeExpected);
|
||||
}
|
||||
|
||||
{
|
||||
const { fixedCssCode } = replaceImportsInInlineCssCode({
|
||||
cssCode,
|
||||
"buildOptions": {
|
||||
"isStandalone": false,
|
||||
"urlOrigin": "https://demo-app.keycloakify.dev",
|
||||
"urlPathname": "/x/y/z/"
|
||||
}
|
||||
expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
`;
|
||||
|
||||
assetIsSameCode(fixedCssCode, fixedCssCodeExpected);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("PASS replace import from static");
|
||||
});
|
||||
});
|
77
test/bin/setupSampleReactProject.spec.ts
Normal file
77
test/bin/setupSampleReactProject.spec.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import * as fs from "fs";
|
||||
import { getProjectRoot } from "keycloakify/bin/tools/getProjectRoot.js";
|
||||
import { join as pathJoin } from "path";
|
||||
import { downloadAndUnzip } from "keycloakify/bin/tools/downloadAndUnzip";
|
||||
import { main as initializeEmailTheme } from "keycloakify/bin/initialize-email-theme";
|
||||
import { it, describe, afterAll, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { getKeycloakBuildPath } from "keycloakify/bin/keycloakify/build-paths";
|
||||
import { downloadBuiltinKeycloakTheme } from "keycloakify/bin/download-builtin-keycloak-theme";
|
||||
|
||||
export const sampleReactProjectDirPath = pathJoin(getProjectRoot(), "sample_react_project");
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
let parsedPackageJson: Record<string, unknown> = {};
|
||||
vi.mock("keycloakify/bin/keycloakify/parsed-package-json", async () => ({
|
||||
...((await vi.importActual("keycloakify/bin/keycloakify/parsed-package-json")) as Record<string, unknown>),
|
||||
getParsedPackageJson: () => parsedPackageJson
|
||||
}));
|
||||
|
||||
vi.mock("keycloakify/bin/promptKeycloakVersion", async () => ({
|
||||
...((await vi.importActual("keycloakify/bin/promptKeycloakVersion")) as Record<string, unknown>),
|
||||
promptKeycloakVersion: () => ({ keycloakVersion: "11.0.3" })
|
||||
}));
|
||||
|
||||
const nativeCwd = process.cwd;
|
||||
|
||||
describe("Sample Project", () => {
|
||||
beforeAll(() => {
|
||||
// Monkey patching the cwd to the app location for the duration of this test
|
||||
process.cwd = () => sampleReactProjectDirPath;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(sampleReactProjectDirPath, { "recursive": true });
|
||||
process.cwd = nativeCwd;
|
||||
});
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(sampleReactProjectDirPath)) {
|
||||
fs.rmSync(sampleReactProjectDirPath, { "recursive": true });
|
||||
}
|
||||
|
||||
fs.mkdirSync(pathJoin(sampleReactProjectDirPath, "src", "keycloak-theme"), { "recursive": true });
|
||||
fs.mkdirSync(pathJoin(sampleReactProjectDirPath, "src", "login"), { "recursive": true });
|
||||
});
|
||||
it(
|
||||
"Sets up the project without error",
|
||||
async () => {
|
||||
await setupSampleReactProject(sampleReactProjectDirPath);
|
||||
await initializeEmailTheme();
|
||||
|
||||
const destDirPath = pathJoin(getKeycloakBuildPath(), "src", "main", "resources", "theme");
|
||||
await downloadBuiltinKeycloakTheme({ destDirPath, keycloakVersion: "11.0.3", isSilent: false });
|
||||
},
|
||||
{ timeout: 90000 }
|
||||
);
|
||||
it(
|
||||
"Sets up the project with a custom input and output directory without error",
|
||||
async () => {
|
||||
parsedPackageJson = {
|
||||
"keycloakify": {
|
||||
"appInputPath": "./custom_input/build",
|
||||
"keycloakBuildDir": "./custom_output"
|
||||
}
|
||||
};
|
||||
await setupSampleReactProject(pathJoin(sampleReactProjectDirPath, "custom_input"));
|
||||
await initializeEmailTheme();
|
||||
|
||||
const destDirPath = pathJoin(getKeycloakBuildPath(), "src", "main", "resources", "theme");
|
||||
await downloadBuiltinKeycloakTheme({ destDirPath, keycloakVersion: "11.0.3", isSilent: false });
|
||||
},
|
||||
{ timeout: 90000 }
|
||||
);
|
||||
});
|
@ -1,14 +1,8 @@
|
||||
import { getProjectRoot } from "keycloakify/bin/tools/getProjectRoot.js";
|
||||
import { join as pathJoin } from "path";
|
||||
import { downloadAndUnzip } from "keycloakify/bin/tools/downloadAndUnzip";
|
||||
|
||||
export const sampleReactProjectDirPath = pathJoin(getProjectRoot(), "sample_react_project");
|
||||
|
||||
export async function setupSampleReactProject() {
|
||||
export async function setupSampleReactProject(destDirPath: string) {
|
||||
await downloadAndUnzip({
|
||||
"url": "https://github.com/InseeFrLab/keycloakify/releases/download/v0.0.1/sample_build_dir_and_package_json.zip",
|
||||
"destDirPath": sampleReactProjectDirPath,
|
||||
"cacheDirPath": pathJoin(sampleReactProjectDirPath, "build_keycloak", ".cache"),
|
||||
"isSilent": false
|
||||
"url": "https://github.com/keycloakify/keycloakify/releases/download/v0.0.1/sample_build_dir_and_package_json.zip",
|
||||
"destDirPath": destDirPath
|
||||
});
|
||||
}
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { getKcContext } from "../../src/login/kcContext/getKcContext";
|
||||
import type { ExtendKcContext } from "../../src/login/kcContext/getKcContextFromWindow";
|
||||
import type { KcContext } from "../../src/login/kcContext";
|
||||
import { getKcContext } from "keycloakify/login/kcContext/getKcContext";
|
||||
import type { ExtendKcContext } from "keycloakify/login/kcContext/getKcContextFromWindow";
|
||||
import type { KcContext } from "keycloakify/login/kcContext";
|
||||
import { same } from "evt/tools/inDepth";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
import { kcContextMocks, kcContextCommonMock } from "../../src/login/kcContext/kcContextMocks";
|
||||
import { deepClone } from "../../src/tools/deepClone";
|
||||
import { kcContextMocks, kcContextCommonMock } from "keycloakify/login/kcContext/kcContextMocks";
|
||||
import { deepClone } from "keycloakify/tools/deepClone";
|
||||
import { expect, it, describe } from "vitest";
|
||||
|
||||
{
|
||||
describe("getKcContext", () => {
|
||||
const authorizedMailDomains = ["example.com", "another-example.com", "*.yet-another-example.com", "*.example.com", "hello-world.com"];
|
||||
|
||||
const displayName = "this is an overwritten common value";
|
||||
@ -59,8 +60,7 @@ import { deepClone } from "../../src/tools/deepClone";
|
||||
|
||||
return { kcContext };
|
||||
};
|
||||
|
||||
{
|
||||
it("has proper API for login.ftl", () => {
|
||||
const pageId = "login.ftl";
|
||||
|
||||
const { kcContext } = getKcContextProxy({ "mockPageId": pageId });
|
||||
@ -69,7 +69,7 @@ import { deepClone } from "../../src/tools/deepClone";
|
||||
|
||||
assert<Equals<typeof kcContext, KcContext.Login>>();
|
||||
|
||||
assert(
|
||||
expect(
|
||||
same(
|
||||
//NOTE: deepClone for printIfExists or other functions...
|
||||
deepClone(kcContext),
|
||||
@ -81,12 +81,10 @@ import { deepClone } from "../../src/tools/deepClone";
|
||||
return mock;
|
||||
})()
|
||||
)
|
||||
);
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
console.log(`PASS ${pageId}`);
|
||||
}
|
||||
|
||||
{
|
||||
it("has a proper API for info.ftl", () => {
|
||||
const pageId = "info.ftl";
|
||||
|
||||
const { kcContext } = getKcContextProxy({ "mockPageId": pageId });
|
||||
@ -104,7 +102,7 @@ import { deepClone } from "../../src/tools/deepClone";
|
||||
>
|
||||
>();
|
||||
|
||||
assert(
|
||||
expect(
|
||||
same(
|
||||
deepClone(kcContext),
|
||||
(() => {
|
||||
@ -115,12 +113,9 @@ import { deepClone } from "../../src/tools/deepClone";
|
||||
return mock;
|
||||
})()
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`PASS ${pageId}`);
|
||||
}
|
||||
|
||||
{
|
||||
).toBe(true);
|
||||
});
|
||||
it("has a proper API for register.ftl", () => {
|
||||
const pageId = "register.ftl";
|
||||
|
||||
const { kcContext } = getKcContextProxy({ "mockPageId": pageId });
|
||||
@ -138,7 +133,7 @@ import { deepClone } from "../../src/tools/deepClone";
|
||||
>
|
||||
>();
|
||||
|
||||
assert(
|
||||
expect(
|
||||
same(
|
||||
deepClone(kcContext),
|
||||
(() => {
|
||||
@ -149,12 +144,9 @@ import { deepClone } from "../../src/tools/deepClone";
|
||||
return mock;
|
||||
})()
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`PASS ${pageId}`);
|
||||
}
|
||||
|
||||
{
|
||||
).toBe(true);
|
||||
});
|
||||
it("has a proper API for my-extra-page-2.ftl", () => {
|
||||
const pageId = "my-extra-page-2.ftl";
|
||||
|
||||
const { kcContext } = getKcContextProxy({ "mockPageId": pageId });
|
||||
@ -173,7 +165,7 @@ import { deepClone } from "../../src/tools/deepClone";
|
||||
|
||||
kcContext.aNonStandardValue2;
|
||||
|
||||
assert(
|
||||
expect(
|
||||
same(
|
||||
deepClone(kcContext),
|
||||
(() => {
|
||||
@ -184,12 +176,9 @@ import { deepClone } from "../../src/tools/deepClone";
|
||||
return mock;
|
||||
})()
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`PASS ${pageId}`);
|
||||
}
|
||||
|
||||
{
|
||||
).toBe(true);
|
||||
});
|
||||
it("has a proper API for my-extra-page-1.ftl", () => {
|
||||
const pageId = "my-extra-page-1.ftl";
|
||||
|
||||
console.log("We expect a warning here =>");
|
||||
@ -200,7 +189,7 @@ import { deepClone } from "../../src/tools/deepClone";
|
||||
|
||||
assert<Equals<typeof kcContext, KcContext.Common & { pageId: typeof pageId }>>();
|
||||
|
||||
assert(
|
||||
expect(
|
||||
same(
|
||||
deepClone(kcContext),
|
||||
(() => {
|
||||
@ -211,32 +200,24 @@ import { deepClone } from "../../src/tools/deepClone";
|
||||
return mock;
|
||||
})()
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`PASS ${pageId}`);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const pageId = "login.ftl";
|
||||
|
||||
const { kcContext } = getKcContext({
|
||||
"mockPageId": pageId
|
||||
).toBe(true);
|
||||
});
|
||||
it("returns the proper mock for login.ftl", () => {
|
||||
const pageId = "login.ftl";
|
||||
|
||||
assert<Equals<typeof kcContext, KcContext | undefined>>();
|
||||
const { kcContext } = getKcContext({
|
||||
"mockPageId": pageId
|
||||
});
|
||||
|
||||
assert(same(deepClone(kcContext), deepClone(kcContextMocks.find(({ pageId: pageId_i }) => pageId_i === pageId)!)));
|
||||
assert<Equals<typeof kcContext, KcContext | undefined>>();
|
||||
|
||||
console.log("PASS no extension");
|
||||
}
|
||||
assert(same(deepClone(kcContext), deepClone(kcContextMocks.find(({ pageId: pageId_i }) => pageId_i === pageId)!)));
|
||||
});
|
||||
it("returns the proper mock for login.ftl", () => {
|
||||
const { kcContext } = getKcContext();
|
||||
|
||||
{
|
||||
const { kcContext } = getKcContext();
|
||||
assert<Equals<typeof kcContext, KcContext | undefined>>();
|
||||
|
||||
assert<Equals<typeof kcContext, KcContext | undefined>>();
|
||||
|
||||
assert(kcContext === undefined);
|
||||
|
||||
console.log("PASS no extension, no mock");
|
||||
}
|
||||
assert(kcContext === undefined);
|
||||
});
|
||||
});
|
@ -1 +0,0 @@
|
||||
import "./getKcContext";
|
@ -1,4 +1,4 @@
|
||||
import { AndByDiscriminatingKey } from "../../../src/tools/AndByDiscriminatingKey";
|
||||
import { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
|
@ -1,7 +0,0 @@
|
||||
import { assert } from "tsafe/assert";
|
||||
|
||||
export function assetIsSameCode(code1: string, code2: string, message?: string): void {
|
||||
const removeSpacesAndNewLines = (code: string) => code.replace(/\s/g, "").replace(/\n/g, "");
|
||||
|
||||
assert(removeSpacesAndNewLines(code1) === removeSpacesAndNewLines(code2), message);
|
||||
}
|
5
test/tools/isSameCode.ts
Normal file
5
test/tools/isSameCode.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export function isSameCode(code1: string, code2: string): boolean {
|
||||
const removeSpacesAndNewLines = (code: string) => code.replace(/\s/g, "").replace(/\n/g, "");
|
||||
|
||||
return removeSpacesAndNewLines(code1) === removeSpacesAndNewLines(code2);
|
||||
}
|
@ -10,7 +10,7 @@
|
||||
"newLine": "LF",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"incremental": true,
|
||||
"incremental": false,
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"jsx": "react-jsx",
|
||||
|
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from "vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
"test": {
|
||||
"alias": {
|
||||
"keycloakify": path.resolve(__dirname, "./src")
|
||||
},
|
||||
"watchExclude": ["**/node_modules/**", "**/dist/**", "**/sample_react_project/**"]
|
||||
}
|
||||
});
|
Reference in New Issue
Block a user