Compare commits

..

208 Commits

Author SHA1 Message Date
0569fa5e58 Bump version 2024-02-27 23:32:07 +01:00
ba74952e0b #513 2024-02-27 23:19:55 +01:00
20c28f785a Bump version 2024-02-27 06:52:17 +01:00
e9b249ddc7 Fix ftl script bug and definitively address #512 and #432 2024-02-27 06:52:17 +01:00
604bb484a3 Update README.md 2024-02-25 21:02:31 +01:00
010c93793a Bump version 2024-02-24 05:38:59 +01:00
dc1d4a66f4 keycloakifyBuildDirPath was non absolute 2024-02-24 05:38:42 +01:00
8ef633d7ef Bump version 2024-02-24 05:09:08 +01:00
2176d33da1 Support generating source map 2024-02-24 05:08:39 +01:00
5b794e2d22 Bump version 2024-02-23 20:02:41 +01:00
ccd75d56c5 Run the post build script in the react app directory 2024-02-23 19:38:01 +01:00
b700066833 Release candidate 2024-02-23 19:22:28 +01:00
546ee006d3 Rename postBuildScript to postBuild, make the params of the Vite plugin optional 2024-02-23 19:22:14 +01:00
7f333a6a36 Release candidate 2024-02-23 19:16:57 +01:00
ae757ee371 Enable to provide the configuration to the Vite plugin, enable user to provide a post build script #148 2024-02-23 19:16:53 +01:00
69936750d5 Bump version 2024-02-21 20:43:34 +01:00
442bfa4ed6 Update types to reflect what is actually there on the kcContext 2024-02-21 20:43:18 +01:00
2a88e6802f Bump version 2024-02-18 11:28:29 +01:00
bcc8b12e13 Enable to statically build storybook in Vite project 2024-02-18 11:28:17 +01:00
9b974505eb Update ci 2024-02-17 04:15:31 +01:00
29b1c26771 Bump version 2024-02-17 03:47:03 +01:00
02db20d98b Enable to release on v8 branch 2024-02-17 03:47:03 +01:00
757354df7d Follow up on #406 2024-02-17 03:47:03 +01:00
563518cf46 Remove poll 2024-02-13 15:07:14 +01:00
7c42d9082a Remove broken badge 2024-02-13 04:47:14 +01:00
040284af71 Reference Vite doc 2024-02-13 04:46:36 +01:00
34f64184d9 Bump version 2024-02-13 01:33:31 +01:00
b9abd74156 Create a .gitignore that matches all in the build_keycloak directory 2024-02-13 01:33:15 +01:00
a1c0bfda6c Bump version 2024-02-13 01:13:26 +01:00
617dcef09d Merge pull request #499 from keycloakify/vite
Vite
2024-02-13 01:04:54 +01:00
d9c406800a Fix tests 2024-02-12 23:57:21 +01:00
54b869def1 Release candidate 2024-02-12 23:43:21 +01:00
d80a583979 Better test for isStorybook 2024-02-12 23:42:31 +01:00
99bfd7379b Release candidate 2024-02-12 13:43:34 +01:00
5f257382fa Prevent accumulation of assets in build dir 2024-02-12 13:43:12 +01:00
e3e6847c82 Prevent users from having to use any 2024-02-12 13:27:07 +01:00
4ee0823acb Bump version 2024-02-12 01:47:21 +01:00
d466123b1c Use Keycloak 23.0.6 2024-02-12 01:41:08 +01:00
21cbc14a48 Release candidate 2024-02-12 01:34:54 +01:00
b2f2c3e386 Disalow releative basename in vite config 2024-02-12 01:34:34 +01:00
b03340ed10 Do not dynamically import "en" to make vite happy 2024-02-12 00:33:12 +01:00
5b563d8e9b Improve privacy on the buildinfo file that might be served 2024-02-12 00:32:18 +01:00
2790487fc7 Release candidate 2024-02-11 23:59:23 +01:00
ad5a368065 Run vite configResolved in dev mode as well 2024-02-11 23:59:10 +01:00
7c0a631a9a Fix crash in vite-plugin 2024-02-11 23:54:28 +01:00
4a8920749a release candidate 2024-02-11 23:48:09 +01:00
8ab118dd06 Remove path-browserify dependency 2024-02-11 23:47:58 +01:00
e6661cb898 Fix build 2024-02-11 23:22:37 +01:00
1671850714 Release candidate 2024-02-11 20:21:05 +01:00
d568bafe04 Remove unessesary file 2024-02-11 20:20:52 +01:00
43dcce8478 Add cache to getNpmWorkspaceRootDirPath 2024-02-11 20:20:38 +01:00
ad70a4cffd Make Vite run copy-keaycloak-resources-to-public 2024-02-11 20:15:18 +01:00
6d4a948dd8 minor refactor 2024-02-11 19:25:52 +01:00
839ba6a964 Improve monorepo support 2024-02-11 18:28:58 +01:00
b5cfdb9d0a Store vite plugin output in cache dir path 2024-02-11 16:17:58 +01:00
9706338182 Release candidate 2024-02-11 12:05:05 +01:00
05f52c3d23 Remove unessesary log 2024-02-11 12:04:50 +01:00
df3acb6932 Release candidate 2024-02-11 10:38:12 +01:00
b3c242595e Fix PUBLIC_URL typing 2024-02-11 10:37:22 +01:00
26985f8d81 Release candidate 2024-02-11 00:21:21 +01:00
05e5e4efec When is storybook, don't print mock related warning in console 2024-02-11 00:21:04 +01:00
4d67f16e94 Release candidate 2024-02-10 20:40:59 +01:00
334ec1870a Throw if unrecognized bundler when getting BASE_URL for mocks 2024-02-10 20:40:41 +01:00
ef5e4fccd3 Replace the common asset path url in the ftl (upstream) to help pepole figure out what's going on 2024-02-10 20:04:08 +01:00
8535edcfd4 Relase candidate 2024-02-10 19:51:39 +01:00
bda76200d7 Base url that works everywhere in mocks 2024-02-10 19:51:24 +01:00
db0dc96cc7 Release candidate 2024-02-09 17:56:57 +01:00
6d62b5a150 Don't replace process.env.PUBLIC_URL 2024-02-09 17:56:28 +01:00
217439d673 Export polyfill of process.env.PUBLIC_URL 2024-02-09 17:52:26 +01:00
1f79a8f7dc Deprecate keycloakJsAdapter 2024-02-09 17:52:03 +01:00
7596786b18 Release candidate 2024-02-08 23:37:14 +01:00
2540b06c94 Tweak the resources of the default theme that are kept 2024-02-08 23:36:52 +01:00
43eeaf3002 Remove misleading comment in start-keycloak-container script 2024-02-08 14:41:07 +01:00
037cd150de Release candidate 2024-02-08 14:10:10 +01:00
ae0b059217 Fix a couple of bug in donwnload-builtin-keycloak-theme 2024-02-08 14:09:55 +01:00
8255ce1158 Release candidate 2024-02-08 00:56:55 +01:00
5bf905723c Feature -p or --project option for ease of use in monorepo setups #449 2024-02-08 00:56:33 +01:00
3e336f4937 Release candidate 2024-02-08 00:12:50 +01:00
cd1cc37916 Explicitely exclude a number of node deps from Keycloak commons for bundle size 2024-02-08 00:12:10 +01:00
4ad7183d7e Prevent crashing when github is not up to date yet 2024-02-07 21:22:58 +01:00
e1b52e7439 Implement remote caching mechanism to prevent full download of the Keycloak sources 2024-02-07 21:17:09 +01:00
dca8c9f9d7 Merge main, fix runtime error in scripts, fix clean build 2024-02-07 20:01:26 +01:00
7e4eba6376 Update README, add poll 2024-02-07 10:29:46 +01:00
f642a56eaa Release candidate 2024-02-06 08:00:07 +01:00
c091089830 Apply #502 from main 2024-02-06 07:59:21 +01:00
18900d20e1 Update All contributors 2024-02-06 07:56:06 +01:00
6c622b1580 Remove TODO comment 2024-02-06 07:55:32 +01:00
4290cd23b2 Remove what seems to be dead code (not used in the starter nor in onyxia, besite, looks fishy) '//TODO: Write a test case for this' for ref and .chunk.css", 2024-02-06 07:29:48 +01:00
5076c1e93f Unit test passing 2024-02-06 07:28:03 +01:00
884b701fc6 Removing keycloak-resources dir from dist dir after build 2024-02-05 09:26:54 +01:00
73a8ec0295 Building version 2024-02-05 08:52:58 +01:00
a29b6097a4 Reintroduce doBuildRetrocompatAccountTheme (for now) 2024-02-04 10:25:48 +01:00
a9231e2ed8 Bump version 2024-02-04 06:31:10 +01:00
5f4669a7a6 Merge pull request #502 from giorgoslytos/fix/login-otp-radio-inputs 2024-02-03 10:53:58 +01:00
75c54df109 Fix some errors in base account v1 theme 2024-02-03 08:30:06 +01:00
2a07f7151d Merge pull request #503 from keycloakify/all-contributors/add-giorgoslytos
docs: add giorgoslytos as a contributor for code
2024-02-03 08:13:16 +01:00
b6ecff2dd3 docs: update .all-contributorsrc [skip ci] 2024-02-03 07:11:46 +00:00
83df27ec99 docs: update README.md [skip ci] 2024-02-03 07:11:45 +00:00
ca255985c0 fix: radio inputs on login-otp page 2024-02-02 14:35:14 +02:00
82f34c38f6 Bump version 2024-02-01 06:22:54 +01:00
694b4c8027 Reintroduce doBuildRetrocompatAccountTheme (for now) and fix multiple things in the account default theme 2024-02-01 06:22:33 +01:00
bd25621b2c Remove dead code 2024-01-31 21:56:46 +01:00
fde34be270 Compiling build 2024-01-30 07:10:53 +01:00
7c7ce159fe Complete build option 2024-01-30 06:55:26 +01:00
5a57bb59e5 Refactor 2024-01-30 06:38:26 +01:00
cd278f4ab5 Refactor 2024-01-30 06:37:49 +01:00
8b24e23721 refactor 2024-01-30 06:04:05 +01:00
22fa1411bf Moving on 2024-01-30 05:54:36 +01:00
2799a52d0c Implement router for js remplacer depending of the bundle (vite or webpack) 2024-01-30 01:24:44 +01:00
4c2e01a7a8 Done with implementation of webpack js replacer test case for support of non root base 2024-01-30 00:56:47 +01:00
7267d2ef38 Add a test case for the code that enable the import to work in webpack if base isn't / 2024-01-30 00:37:30 +01:00
1eb6b154f7 Rearenge test case 2024-01-30 00:15:08 +01:00
f55d61bf0b Rename test case file 2024-01-30 00:10:59 +01:00
5b350274bd Fundation 2024-01-30 00:06:17 +01:00
b6d2f9f691 Disable tests for now 2024-01-27 19:00:45 +01:00
81106b5deb Release candidate 2024-01-27 18:51:05 +01:00
66e595e649 Vite investigations 2024-01-27 18:49:29 +01:00
33b7bb6184 Bump version 2024-01-26 04:09:54 +01:00
7d9130b2af Remove the doBuildRetrocompatAccountTheme and the retrocompat_ option in dropdown 2024-01-26 03:39:48 +01:00
482d71743b Bump version 2024-01-26 02:03:06 +01:00
1db37a4727 Do not include retrocompat_ in META-INF when using doBuildRetrocompatAccountTheme false 2024-01-26 02:02:45 +01:00
194d16ff91 Bump version 2024-01-26 00:56:45 +01:00
b1e2284c0e Fix condition for displaying info in login page 2024-01-26 00:55:50 +01:00
70d1aa70a3 Bump version 2024-01-18 18:09:59 +01:00
3b17d6e0ab Merge pull request #496 from keycloakify/all-contributors/add-Moulyy
docs: add Moulyy as a contributor for code
2024-01-18 18:09:10 +01:00
9a5819b93b docs: update .all-contributorsrc [skip ci] 2024-01-18 16:45:36 +00:00
a260cd67b0 docs: update README.md [skip ci] 2024-01-18 16:45:35 +00:00
64111fb0ec Merge pull request #495 from Moulyy/fix/add_password_visibility_classkey
Add "kcInputGroup" in ClassKey type definition and useGetClassName
2024-01-18 17:43:48 +01:00
faf2be23d9 Add "kcInputGroup" in ClassKey type definition and the patternfly class associated in useGetClassName 2024-01-18 17:31:16 +01:00
0eb4a6a315 Merge pull request #494 from alexted/patch-1
Update README.md
2024-01-18 16:59:17 +01:00
85673250ed Update README.md
extra "v" deleted  in several places of changelog highlights
2024-01-18 17:48:10 +02:00
09daa741ce Install pnpm if needed 2024-01-18 01:02:14 +01:00
55e2379aab Bump version 2024-01-18 00:57:02 +01:00
9937977203 Merge branch 'main' of https://github.com/keycloakify/keycloakify 2024-01-18 00:56:26 +01:00
c897e7491a Use message of Keycloak version 23.0.4 #493 2024-01-18 00:54:09 +01:00
0a74a95283 Bump version 2024-01-15 11:02:00 +01:00
74ef2c3dff Merge pull request #489 from law108000/main
refactor: point the import statement to keycloakify instead of internal
2024-01-15 11:01:14 +01:00
9976dfacc0 Merge pull request #491 from keycloakify/all-contributors/add-law108000
docs: add law108000 as a contributor for code
2024-01-15 10:59:17 +01:00
659f8ddc7a docs: update .all-contributorsrc [skip ci] 2024-01-15 09:56:30 +00:00
9e4cc2ae57 docs: update README.md [skip ci] 2024-01-15 09:56:29 +00:00
a27d78fcdf refactor: point the import statement to keycloakify instead of internal
align import statement with other page templates
2024-01-15 14:03:01 +08:00
e507435bcb Bump version 2024-01-10 13:01:23 +01:00
d5f234909f Merge branch 'main' of https://github.com/keycloakify/keycloakify 2024-01-10 12:43:11 +01:00
c17f721625 Bump version 2024-01-10 12:43:04 +01:00
600705130f #483: Enable the Template prop not to be lazy 2024-01-10 12:42:48 +01:00
5c5dce1422 Bump version 2023-12-18 13:19:54 +01:00
53585bf2f0 Merge pull request #476 from BlackVoid/feature/document-title
Fixes #473: Set document title using the "loginTitle" translation
2023-12-18 13:18:51 +01:00
116f88a503 Fixes #473: Set document title using the "loginTitle" translation 2023-12-18 11:56:37 +01:00
aaba8cd2c7 Bump version 2023-12-14 15:34:13 +01:00
b67aeb0d3a Fix tests #471 2023-12-14 15:33:57 +01:00
f620562d68 docs: update .all-contributorsrc [skip ci] 2023-12-14 15:09:10 +01:00
5231d0eaa1 docs: update README.md [skip ci] 2023-12-14 15:09:09 +01:00
cb470e3573 Handle CSS with multiple urls 2023-12-12 14:51:09 +00:00
0a0f90aa2e Bump version 2023-12-04 14:10:25 +01:00
635207d12c Generate a README alongside the jars to indicate why there's two jar file 2023-12-04 14:10:10 +01:00
5e4a829413 Bump version 2023-12-01 00:54:36 +01:00
b13b3fd92e Mention the nessesity to use retrocompat- with older Keycloak versions 2023-12-01 00:54:19 +01:00
564dc8e6f1 Bump version 2023-12-01 00:03:58 +01:00
6e4cced8c6 Rename original- to retrocompat- 2023-12-01 00:03:44 +01:00
29a4a5027c Bump version 2023-11-30 22:37:13 +01:00
ee327448b4 Delete original- jar 2023-11-30 22:36:58 +01:00
d078960c5c Bump version 2023-11-30 18:53:17 +01:00
2e8cd375fc Merge pull request #462 from BlackVoid/fix/client-attributes
Fixes #460: Fixes KcContext to contain attributes for client object
2023-11-30 18:52:26 +01:00
1f6751cb01 Fixes #460: Fixes KcContext to contain attributes for client object 2023-11-30 17:17:58 +01:00
3cca4e31cd Merge pull request #463 from keycloakify/all-contributors/add-BlackVoid
docs: add BlackVoid as a contributor for code
2023-11-30 17:10:29 +01:00
b93902800c docs: update .all-contributorsrc [skip ci] 2023-11-30 16:06:55 +00:00
70f6bb3fda docs: update README.md [skip ci] 2023-11-30 16:06:54 +00:00
c075cb6311 Merge pull request #461 from BlackVoid/fix/template
Fixes #459: Use Template from props
2023-11-30 17:03:36 +01:00
d7db85b062 Fixes #459: Use Template from props 2023-11-30 16:29:53 +01:00
b442e7d958 Merge pull request #457 from keycloakify/all-contributors/add-xgp
docs: add xgp as a contributor for code
2023-11-28 13:06:04 +01:00
a495ae637f docs: update .all-contributorsrc [skip ci] 2023-11-28 12:05:49 +00:00
94748a96a9 docs: update README.md [skip ci] 2023-11-28 12:05:48 +00:00
7657429054 Release v9 2023-11-28 12:53:37 +01:00
2ff6dbf975 Update README 2023-11-28 12:53:10 +01:00
4f34628c14 Merge pull request #414 from keycloakify/v9
v9: Support Keycloak 22 and decoupling account / login builtin resources
2023-11-28 12:18:07 +01:00
6ff2111cee #389 https://github.com/p2-inc/keycloak-account-v1/issues/3 2023-11-28 12:17:16 +01:00
85957980f6 update CI 2023-11-26 16:24:57 +01:00
a6dcfe2c87 Release candidate 2023-11-26 16:14:25 +01:00
c32d590fbb #389 https://github.com/xgp/keycloak-account-v1/issues/3 2023-11-26 16:10:34 +01:00
ab41462f71 Actually resolve conflict with main 2023-11-26 15:07:27 +01:00
951f16b1a5 Merge pull request #456 from keycloakify/v9_tmp
Rebase v9 to main
2023-11-26 14:46:28 +01:00
b5818888bb Merge branch 'v9' into v9_tmp 2023-11-26 14:45:23 +01:00
1a326bf7e4 Bump version 2023-11-22 18:58:04 +01:00
e1afc1cf7a Add themeVersion in KcContext type 2023-11-22 18:57:43 +01:00
bb007ddce5 fmt 2023-11-22 11:44:58 +01:00
b5dd0317c7 Update README.md 2023-11-22 11:39:10 +01:00
3c54541a73 Bump version 2023-11-19 03:27:54 +01:00
2657f01135 Enable to ignore part of the HTML 2023-11-19 03:27:40 +01:00
7223409eb1 Bump version 2023-11-07 16:33:33 +01:00
c41eae63e7 Fix info.ftl page rendering in storybook 2023-11-07 16:33:19 +01:00
c8b85c43aa Bump version 2023-11-04 16:47:58 +01:00
e918788c3f Reverse previous change, it breaks cra build 2023-11-04 16:47:13 +01:00
e06ef01f72 Merge branch 'main' into v9 2023-09-22 15:52:23 +02:00
7de54a2cc4 Fix tests 2023-09-04 03:26:30 +02:00
c788b8cc82 Release candidate 2023-09-04 02:49:58 +02:00
cb8db1a541 Fix build 2023-09-04 02:49:32 +02:00
8a7a551c3b Fix mock path error in account 2023-09-04 02:38:19 +02:00
84d180b810 Fix bug with asset paths 2023-09-04 02:34:10 +02:00
de261a27ca Do not display that the jar have been created if we don't create it. 2023-09-04 02:29:16 +02:00
28288a8f7b Build retrocompatible account theme 2023-09-04 02:16:55 +02:00
cd8548fc32 Remove extraThemeNames option in favor of extending themeName to accept array 2023-09-04 01:19:21 +02:00
37dbd49589 Rename extraThemeNames to themeVariantNames 2023-09-04 00:53:57 +02:00
5af8d67b62 Refactor and update docker script 2023-09-04 00:25:36 +02:00
72e6309c4a Fix warning 2023-09-03 23:32:21 +02:00
18f0f3cce1 Refactor build option managment 2023-09-03 23:26:34 +02:00
8c3e9ff192 Remove inhouse bundler, we actually need Maven to build now 2023-09-03 21:10:20 +02:00
21d6d27435 Rename build option, update readme 2023-09-03 21:02:51 +02:00
39ff7913d6 https://github.com/xgp/keycloak-account-v1/issues/3 2023-09-03 07:14:57 +02:00
105 changed files with 3649 additions and 2381 deletions

View File

@ -186,6 +186,51 @@
"contributions": [
"code"
]
},
{
"login": "xgp",
"name": "Garth",
"avatar_url": "https://avatars.githubusercontent.com/u/244253?v=4",
"profile": "https://github.com/xgp",
"contributions": [
"code"
]
},
{
"login": "BlackVoid",
"name": "Felix Gustavsson",
"avatar_url": "https://avatars.githubusercontent.com/u/673720?v=4",
"profile": "https://github.com/BlackVoid",
"contributions": [
"code"
]
},
{
"login": "msiemens",
"name": "Markus Siemens",
"avatar_url": "https://avatars.githubusercontent.com/u/1873922?v=4",
"profile": "https://m-siemens.de/",
"contributions": [
"code"
]
},
{
"login": "law108000",
"name": "Rlok",
"avatar_url": "https://avatars.githubusercontent.com/u/8112024?v=4",
"profile": "https://github.com/law108000",
"contributions": [
"code"
]
},
{
"login": "Moulyy",
"name": "Moulyy",
"avatar_url": "https://avatars.githubusercontent.com/u/115405804?v=4",
"profile": "https://github.com/Moulyy",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

View File

@ -3,7 +3,6 @@ on:
push:
branches:
- main
- v*
pull_request:
branches:
- main

140
README.md
View File

@ -14,9 +14,6 @@
<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/keycloakify/keycloakify/blob/729503fe31a155a823f46dd66ad4ff34ca274e0a/tsconfig.json#L14">
<img src="https://camo.githubusercontent.com/0f9fcc0ac1b8617ad4989364f60f78b2d6b32985ad6a508f215f14d8f897b8d3/68747470733a2f2f62616467656e2e6e65742f62616467652f547970655363726970742f7374726963742532302546302539462539322541412f626c7565">
</a>
<a href="https://github.com/thomasdarimont/awesome-keycloak">
<img src="https://awesome.re/mentioned-badge.svg"/>
</a>
@ -36,26 +33,14 @@
<p align="center">
<i>This build tool generates a Keycloak theme <a href="https://www.keycloakify.dev">Learn more</a></i>
<img src="https://user-images.githubusercontent.com/6702424/110260457-a1c3d380-7fac-11eb-853a-80459b65626b.png">
<br/>
<br/>
<img width="400" src="https://github.com/keycloakify/keycloakify/assets/6702424/e66d105c-c06f-47d1-8a31-a6ab09da4e80">
</p>
> Whether or not React is your preferred framework, Keycloakify
> offers a solid option for building Keycloak themes.
> It's not just a convenient way to create a Keycloak theme
> when using React; it's a well-regarded solution that many
> developers appreciate.
Keycloakify is fully compatible with Keycloak 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, [~~22~~](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1822509763), **23** [and up](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)!
> 📣 🛑 Account themes generated by Keycloakify are not currently compatible with Keycloak 22.
> We are working on a solution. [Follow progress](https://github.com/keycloakify/keycloakify/issues/389).
> Login and email themes are not affected.
> UPDATE: [The PR](https://github.com/keycloak/keycloak/pull/22317) that should future proof Keycloakify account themes has been greenlighted
> by the Keycloak team. Resolution is only a matter of time.
Keycloakify is fully compatible with Keycloak, starting from version 11 and is anticipated to maintain compatibility with all future versions.
You can update your Keycloak, your Keycloakify generated theme won't break.
To understand the basis of my confidence in this, you can [visit this discussion thread where I've explained in detail](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791).
## Sponsor 👼
## Sponsor
We are exclusively sponsored by [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), a French company offering Keycloak as a service.
Their dedicated support helps us continue the development and maintenance of this project.
@ -119,6 +104,14 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zavoloklom"><img src="https://avatars.githubusercontent.com/u/4151869?v=4?s=100" width="100px;" alt="Sergey Kupletsky"/><br /><sub><b>Sergey Kupletsky</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=zavoloklom" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=zavoloklom" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rome-user"><img src="https://avatars.githubusercontent.com/u/114131048?v=4?s=100" width="100px;" alt="rome-user"/><br /><sub><b>rome-user</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=rome-user" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/celinepelletier"><img src="https://avatars.githubusercontent.com/u/82821620?v=4?s=100" width="100px;" alt="Céline Pelletier"/><br /><sub><b>Céline Pelletier</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=celinepelletier" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xgp"><img src="https://avatars.githubusercontent.com/u/244253?v=4?s=100" width="100px;" alt="Garth"/><br /><sub><b>Garth</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=xgp" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/BlackVoid"><img src="https://avatars.githubusercontent.com/u/673720?v=4?s=100" width="100px;" alt="Felix Gustavsson"/><br /><sub><b>Felix Gustavsson</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=BlackVoid" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://m-siemens.de/"><img src="https://avatars.githubusercontent.com/u/1873922?v=4?s=100" width="100px;" alt="Markus Siemens"/><br /><sub><b>Markus Siemens</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=msiemens" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/law108000"><img src="https://avatars.githubusercontent.com/u/8112024?v=4?s=100" width="100px;" alt="Rlok"/><br /><sub><b>Rlok</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=law108000" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Moulyy"><img src="https://avatars.githubusercontent.com/u/115405804?v=4?s=100" width="100px;" alt="Moulyy"/><br /><sub><b>Moulyy</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=Moulyy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/giorgoslytos"><img src="https://avatars.githubusercontent.com/u/50946162?v=4?s=100" width="100px;" alt="giorgoslytos"/><br /><sub><b>giorgoslytos</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=giorgoslytos" title="Code">💻</a></td>
</tr>
</tbody>
</table>
@ -130,6 +123,28 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
# Changelog highlights
## 9.5
- Post build hook: You can now apply custom transformation to your theme files. [Learn more](https://docs.keycloakify.dev/build-options#postbuild-hook).
- You can now specify your option in the Keycloakify's Vite plugin instead in the package.json. [See example](https://docs.keycloakify.dev/build-options#themename).
## 9.4
**Vite Support! 🎉**
- [The starter is now a Vite project](https://github.com/keycloakify/keycloakify-starter).
The Webpack based starter is accessible [here](https://github.com/keycloakify/keycloakify-starter-cra).
- CRA (Webpack) remains supported for the forseable future.
- If you have a CRA Keycloakify theme that you wish to migrate to Vite checkout [this migration guide](https://docs.keycloakify.dev/migration-guides/cra-greater-than-vite).
## 9.0
Bring back support for account themes in Keycloak v23 and up! [See issue](https://github.com/keycloakify/keycloakify/issues/389).
### Breaking changes
Very few. Check them out [here](https://docs.keycloakify.dev/migration-guides/v8-greater-than-v9).
## 8.0
- Much smaller .jar size. 70.2 MB -> 7.8 MB.
@ -138,58 +153,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
### Breaking changes
There are very few breaking changes in this major version.
- The [`--external-assets` build option has been removed](https://docs.keycloakify.dev/v/v7/build-options#external-assets-deprecated) it was a performance optimization that is no longer relevant now that
we have lazy loading.
- `kcContext.usernameEditDisabled` is now `kcContext.usernameHidden`, the type was lying, it has been updated to reflect what's actually on the `kcContext` at runtime.
If you want to see in detail what should be updated [see issue](https://github.com/keycloakify/keycloakify/pull/399), or you can search and replace `usernameEditDisabled` -> `usernameHidden` it'll do the trick.
- The `usePrepareTemplate` prototype has been changed, you can search and replace:
`src/keycloak-theme/login/Template.tsx`
```ts
url,
"stylesCommon": [
"node_modules/patternfly/dist/css/patternfly.min.css",
"node_modules/patternfly/dist/css/patternfly-additions.min.css",
"lib/zocial/zocial.css"
],
"styles": ["css/login.css"],
```
by
```ts
"styles": [
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesCommonPath}/lib/zocial/zocial.css`,
`${url.resourcesPath}/css/login.css`
],
```
and
`src/keycloak-theme/account/Template.css`
```ts
url,
"stylesCommon": ["node_modules/patternfly/dist/css/patternfly.min.css", "node_modules/patternfly/dist/css/patternfly-additions.min.css"],
"styles": ["css/account.css"],
```
by
```ts
"styles": [
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
`${url.resourcesPath}/css/account.css`
],
```
There are very few breaking changes in this major version. [Check them out](https://docs.keycloakify.dev/migration-guides/v7-greater-than-v8).
## 7.15
@ -211,7 +175,7 @@ by
## 7.12
- You can now pack multiple themes variant in a single `.jar` bundle. In vanilla Keycloak themes you have the ability to extend a base theme.
There is now an idiomatic way of achieving the same result. [Learn more](https://docs.keycloakify.dev/build-options#keycloakify.extrathemenames).
There is now an idiomatic way of achieving the same result. [Learn more](https://docs.keycloakify.dev/build-options#keycloakify.themeVariantNames).
## 7.9
@ -306,79 +270,79 @@ Checkout [the migration guide](https://docs.keycloakify.dev/v5-to-v6)
Fix `login-verify-email.ftl` page. [Before](https://user-images.githubusercontent.com/6702424/177436014-0bad22c4-5bfb-45bb-8fc9-dad65143cd0c.png) - [After](https://user-images.githubusercontent.com/6702424/177435797-ec5d7db3-84cf-49cb-8efc-3427a81f744e.png)
## v5.6.0
## 5.6.0
Add support for `login-config-totp.ftl` page [#127](https://github.com/keycloakify/keycloakify/pull/127).
## v5.3.0
## 5.3.0
Rename `keycloak_theme_email` to `keycloak_email`.
If you already had a `keycloak_theme_email` you should rename it `keycloak_email`.
## v5.0.0
## 5.0.0
[Migration guide](https://github.com/garronej/keycloakify-demo-app/blob/a5b6a50f24bc25e082931f5ad9ebf47492acd12a/src/index.tsx#L46-L63)
New i18n system.
Import of terms and services have changed. [See example](https://github.com/garronej/keycloakify-demo-app/blob/a5b6a50f24bc25e082931f5ad9ebf47492acd12a/src/index.tsx#L46-L63).
## v4.10.0
## 4.10.0
Add `login-idp-link-email.ftl` page [See PR](https://github.com/keycloakify/keycloakify/pull/92).
## v4.8.0
## 4.8.0
[Email template customization.](#email-template-customization)
## v4.7.4
## 4.7.4
**M1 Mac** support (for testing locally with a dockerized Keycloak).
## v4.7.2
## 4.7.2
> WARNING: This is broken.
> 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
## 4.7.0
Register with user profile enabled: Out of the box `options` validator support.
[Example](https://user-images.githubusercontent.com/6702424/158911163-81e6bbe8-feb0-4dc8-abff-de199d7a678e.mov)
## v4.6.0
## 4.6.0
`tss-react` and `powerhooks` are no longer peer dependencies of `keycloakify`.
After updating Keycloakify you can remove `tss-react` and `powerhooks` from your dependencies if you don't use them explicitly.
## v4.5.3
## 4.5.3
There is a new recommended way to setup highly customized theme. See [here](https://github.com/garronej/keycloakify-demo-app/blob/look_and_feel/src/KcApp/KcApp.tsx).
Unlike with [the previous recommended method](https://github.com/garronej/keycloakify-demo-app/blob/a51660578bea15fb3e506b8a2b78e1056c6d68bb/src/KcApp/KcApp.tsx),
with this new method your theme wont break on minor Keycloakify update.
## v4.3.0
## 4.3.0
Feature [`login-update-password.ftl`](https://user-images.githubusercontent.com/6702424/147517600-6191cf72-93dd-437b-a35c-47180142063e.png).
Every time a page is added it's a breaking change for non CSS-only theme.
Change [this](https://github.com/garronej/keycloakify-demo-app/blob/df664c13c77ce3c53ac7df0622d94d04e76d3f9f/src/KcApp/KcApp.tsx#L17) and [this](https://github.com/garronej/keycloakify-demo-app/blob/df664c13c77ce3c53ac7df0622d94d04e76d3f9f/src/KcApp/KcApp.tsx#L37) to update.
## v4
## 4
- Out of the box [frontend form validation](#user-profile-and-frontend-form-validation) 🥳
- Improvements (and breaking changes in `import { useKcMessage } from "keycloakify"`.
## v3
## 3
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/keycloakify/keycloakify#implement-context-persistence-optional).
## v2.5
## 2.5
- 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
## 2
- It's now possible to implement custom `.ftl` pages.
- Support for Keycloak plugins that introduce non standard ftl values.

View File

@ -1,73 +0,0 @@
{
"allOf": [
{
"$ref": "https://json.schemastore.org/package.json"
},
{
"$ref": "keycloakifyPackageJsonSchema"
}
],
"$ref": "#/definitions/keycloakifyPackageJsonSchema",
"definitions": {
"keycloakifyPackageJsonSchema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"version": {
"type": "string"
},
"homepage": {
"type": "string"
},
"keycloakify": {
"type": "object",
"properties": {
"extraPages": {
"type": "array",
"items": {
"type": "string"
}
},
"extraThemeProperties": {
"type": "array",
"items": {
"type": "string"
}
},
"areAppAndKeycloakServerSharingSameDomain": {
"type": "boolean"
},
"artifactId": {
"type": "string"
},
"groupId": {
"type": "string"
},
"bundler": {
"type": "string",
"enum": ["mvn", "keycloakify", "none"]
},
"keycloakVersionDefaultAssets": {
"type": "string"
},
"reactAppBuildDirPath": {
"type": "string"
},
"keycloakifyBuildDirPath": {
"type": "string"
},
"themeName": {
"type": "string"
}
},
"additionalProperties": false
}
},
"required": ["name", "version"],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@ -1,6 +1,6 @@
{
"name": "keycloakify",
"version": "8.3.1",
"version": "9.5.4",
"description": "Create Keycloak themes using React",
"repository": {
"type": "git",
@ -10,10 +10,10 @@
"types": "dist/index.d.ts",
"scripts": {
"prepare": "yarn generate-i18n-messages",
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/ && cp -r src dist/",
"build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc -p src/vite-plugin && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/ && cp -r src dist/",
"generate:json-schema": "ts-node scripts/generate-json-schema.ts",
"grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js",
"copy-files": "copyfiles -u 1 src/**/*.ftl",
"copy-files": "copyfiles -u 1 src/**/*.ftl src/**/*.java",
"test": "yarn test:types && vitest run",
"test:keycloakify-starter": "ts-node scripts/test-keycloakify-starter",
"test:types": "tsc -p test/tsconfig.json --noEmit",
@ -105,7 +105,8 @@
"tss-react": "^4.8.2",
"typescript": "^4.9.1-beta",
"vitest": "^0.29.8",
"zod-to-json-schema": "^3.20.4"
"zod-to-json-schema": "^3.20.4",
"vite": "^5.0.12"
},
"dependencies": {
"@babel/generator": "^7.22.9",
@ -118,13 +119,13 @@
"make-fetch-happen": "^11.0.3",
"minimal-polyfills": "^2.2.2",
"minimist": "^1.2.6",
"path-browserify": "^1.0.1",
"react-markdown": "^5.0.3",
"recast": "^0.23.3",
"rfc4648": "^1.5.2",
"tsafe": "^1.6.0",
"yauzl": "^2.10.0",
"yazl": "^2.5.1",
"zod": "^3.17.10"
"zod": "^3.17.10",
"magic-string": "^0.30.7"
}
}

View File

@ -3,7 +3,7 @@ import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname, sep as pathSep } from "path";
import { crawl } from "../src/bin/tools/crawl";
import { downloadBuiltinKeycloakTheme } from "../src/bin/download-builtin-keycloak-theme";
import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { getLogger } from "../src/bin/tools/logger";
// NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files,
@ -17,16 +17,25 @@ const isSilent = true;
const logger = getLogger({ isSilent });
async function main() {
const keycloakVersion = "21.0.1";
const keycloakVersion = "23.0.4";
const tmpDirPath = pathJoin(getProjectRoot(), "tmp_xImOef9dOd44");
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
const tmpDirPath = pathJoin(thisCodebaseRootDirPath, "tmp_xImOef9dOd44");
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
fs.mkdirSync(tmpDirPath);
fs.writeFileSync(pathJoin(tmpDirPath, ".gitignore"), Buffer.from("/*\n!.gitignore\n", "utf8"));
await downloadBuiltinKeycloakTheme({
"projectDirPath": getProjectRoot(),
keycloakVersion,
"destDirPath": tmpDirPath
"destDirPath": tmpDirPath,
"buildOptions": {
"cacheDirPath": pathJoin(thisCodebaseRootDirPath, "node_modules", ".cache", "keycloakify"),
"npmWorkspaceRootDirPath": thisCodebaseRootDirPath
}
});
type Dictionary = { [idiomId: string]: string };
@ -66,14 +75,13 @@ async function main() {
return;
}
const baseMessagesDirPath = pathJoin(getProjectRoot(), "src", themeType, "i18n", "baseMessages");
const baseMessagesDirPath = pathJoin(thisCodebaseRootDirPath, "src", themeType, "i18n", "baseMessages");
const languages = Object.keys(recordForPageType);
const generatedFileHeader = [
`//This code was automatically generated by running ${pathRelative(getProjectRoot(), __filename)}`,
"//PLEASE DO NOT EDIT MANUALLY",
""
`//This code was automatically generated by running ${pathRelative(thisCodebaseRootDirPath, __filename)}`,
"//PLEASE DO NOT EDIT MANUALLY"
].join("\n");
languages.forEach(language => {
@ -86,6 +94,7 @@ async function main() {
Buffer.from(
[
generatedFileHeader,
"",
"/* spell-checker: disable */",
`const messages= ${JSON.stringify(recordForPageType[language], null, 2)};`,
"",
@ -104,10 +113,15 @@ async function main() {
Buffer.from(
[
generatedFileHeader,
`import * as en from "./en";`,
"",
"export async function getMessages(currentLanguageTag: string) {",
" const { default: messages } = await (() => {",
" switch (currentLanguageTag) {",
...languages.map(language => ` case "${language}": return import("./${language}");`),
` case "en": return en;`,
...languages
.filter(language => language !== "en")
.map(language => ` case "${language}": return import("./${language}");`),
' default: return { "default": {} };',
" }",
" })();",

View File

@ -1,14 +0,0 @@
import fs from "fs";
import path from "path";
import zodToJsonSchema from "zod-to-json-schema";
import { zParsedPackageJson } from "../src/bin/keycloakify/parsedPackageJson";
const jsonSchemaName = "keycloakifyPackageJsonSchema";
const jsonSchema = zodToJsonSchema(zParsedPackageJson, jsonSchemaName);
const baseProperties = {
// merges package.json schema with keycloakify properties
"allOf": [{ "$ref": "https://json.schemastore.org/package.json" }, { "$ref": jsonSchemaName }]
};
fs.writeFileSync(path.join(process.cwd(), "keycloakify-json-schema.json"), JSON.stringify({ ...baseProperties, ...jsonSchema }, null, 2));

View File

@ -1,11 +1,11 @@
import { execSync } from "child_process";
import { join as pathJoin, relative as pathRelative } from "path";
import { getProjectRoot } from "../src/bin/tools/getProjectRoot";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import * as fs from "fs";
const singletonDependencies: string[] = ["react", "@types/react"];
const rootDirPath = getProjectRoot();
const rootDirPath = getThisCodebaseRootDirPath();
//NOTE: This is only required because of: https://github.com/garronej/ts-ci/blob/c0e207b9677523d4ec97fe672ddd72ccbb3c1cc4/README.md?plain=1#L54-L58
fs.writeFileSync(

21
src/PUBLIC_URL.ts Normal file
View File

@ -0,0 +1,21 @@
import { nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir } from "keycloakify/bin/constants";
import { assert } from "tsafe/assert";
/**
* This is an equivalent of process.env.PUBLIC_URL thay you can use in Webpack projects.
* This works both in your main app and in your Keycloak theme.
*/
export const PUBLIC_URL = (() => {
const kcContext = (window as any)[nameOfTheGlobal];
if (kcContext === undefined || process.env.NODE_ENV === "development") {
assert(
process.env.PUBLIC_URL !== undefined,
`If you use keycloakify/PUBLIC_URL you should be in Webpack and thus process.env.PUBLIC_URL should be defined`
);
return process.env.PUBLIC_URL;
}
return `${kcContext.url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}`;
})();

View File

@ -1,11 +1,13 @@
import type { AccountThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
import type { AccountThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import { type ThemeType } from "keycloakify/bin/constants";
export type KcContext = KcContext.Password | KcContext.Account;
export declare namespace KcContext {
export type Common = {
themeVersion: string;
keycloakifyVersion: string;
themeType: "account";
themeName: string;

View File

@ -1,10 +1,8 @@
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import { deepAssign } from "keycloakify/tools/deepAssign";
import { isStorybook } from "keycloakify/lib/isStorybook";
import type { ExtendKcContext } from "./getKcContextFromWindow";
import { getKcContextFromWindow } from "./getKcContextFromWindow";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { pathBasename } from "keycloakify/tools/pathBasename";
import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { symToStr } from "tsafe/symToStr";
import { kcContextMocks, kcContextCommonMock } from "keycloakify/account/kcContext/kcContextMocks";
@ -28,7 +26,13 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
if (mockPageId !== undefined && realKcContext === undefined) {
//TODO maybe trow if no mock fo custom page
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
warn_that_mock_is_enbaled: {
if (isStorybook) {
break warn_that_mock_is_enbaled;
}
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
}
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
@ -89,12 +93,6 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
return { "kcContext": undefined as any };
}
{
const { url } = realKcContext;
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(resourcesCommonDirPathRelativeToPublicDir));
}
return { "kcContext": realKcContext as any };
}

View File

@ -1,5 +1,5 @@
import type { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey";
import { ftlValuesGlobalName } from "keycloakify/bin/keycloakify/ftlValuesGlobalName";
import { nameOfTheGlobal } from "keycloakify/bin/constants";
import type { KcContext } from "./KcContext";
export type ExtendKcContext<KcContextExtension extends { pageId: string }> = [KcContextExtension] extends [never]
@ -7,5 +7,5 @@ export type ExtendKcContext<KcContextExtension extends { pageId: string }> = [Kc
: AndByDiscriminatingKey<"pageId", KcContextExtension & KcContext.Common, KcContext>;
export function getKcContextFromWindow<KcContextExtension extends { pageId: string } = never>(): ExtendKcContext<KcContextExtension> | undefined {
return typeof window === "undefined" ? undefined : (window as any)[ftlValuesGlobalName];
return typeof window === "undefined" ? undefined : (window as any)[nameOfTheGlobal];
}

View File

@ -1,18 +1,19 @@
import "minimal-polyfills/Object.fromEntries";
import { resourcesCommonDirPathRelativeToPublicDir, resourcesDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { resources_common, keycloak_resources } from "keycloakify/bin/constants";
import { id } from "tsafe/id";
import type { KcContext } from "./KcContext";
import { BASE_URL } from "keycloakify/lib/BASE_URL";
const PUBLIC_URL = (typeof process !== "object" ? undefined : process.env?.["PUBLIC_URL"]) || "/";
const resourcesPath = `${BASE_URL}${keycloak_resources}/account/resources`;
export const kcContextCommonMock: KcContext.Common = {
"themeVersion": "0.0.0",
"keycloakifyVersion": "0.0.0",
"themeType": "account",
"themeName": "my-theme-name",
"url": {
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
resourcesPath,
"resourcesCommonPath": `${resourcesPath}/${resources_common}`,
"resourceUrl": "#",
"accountUrl": "#",
"applicationsUrl": "#",

View File

@ -1,10 +1,11 @@
import type { LazyExoticComponent } from "react";
import type { I18n } from "keycloakify/account/i18n";
import type { TemplateProps, ClassKey } from "keycloakify/account/TemplateProps";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { KcContext } from "keycloakify/account/kcContext";
export type PageProps<KcContext, I18nExtended extends I18n> = {
Template: LazyExoticComponent<(props: TemplateProps<any, any>) => JSX.Element | null>;
kcContext: KcContext;
export type PageProps<NarowedKcContext = KcContext, I18nExtended extends I18n = I18n> = {
Template: LazyOrNot<(props: TemplateProps<any, any>) => JSX.Element | null>;
kcContext: NarowedKcContext;
i18n: I18nExtended;
doUseDefaultCss: boolean;
classes?: Partial<Record<ClassKey, string>>;

14
src/bin/constants.ts Normal file
View File

@ -0,0 +1,14 @@
export const nameOfTheGlobal = "kcContext";
export const keycloak_resources = "keycloak-resources";
export const resources_common = "resources-common";
export const lastKeycloakVersionWithAccountV1 = "21.1.2";
export const resolvedViteConfigJsonBasename = "vite.json";
export const basenameOfTheKeycloakifyResourcesDir = "build";
export const themeTypes = ["login", "account"] as const;
export const retrocompatPostfix = "_retrocompat";
export const accountV1ThemeName = "account-v1";
export type ThemeType = (typeof themeTypes)[number];
export const keycloakifyBuildOptionsForPostPostBuildScriptEnvName = "KEYCLOAKIFY_BUILD_OPTIONS_POST_POST_BUILD_SCRIPT";

View File

@ -1,39 +1,63 @@
#!/usr/bin/env node
import { downloadKeycloakStaticResources } from "./keycloakify/generateTheme/downloadKeycloakStaticResources";
import { downloadKeycloakStaticResources, type BuildOptionsLike } from "./keycloakify/generateTheme/downloadKeycloakStaticResources";
import { join as pathJoin, relative as pathRelative } from "path";
import { basenameOfKeycloakDirInPublicDir } from "./mockTestingResourcesPath";
import { readBuildOptions } from "./keycloakify/BuildOptions";
import { themeTypes } from "./keycloakify/generateFtl";
import { readBuildOptions } from "./keycloakify/buildOptions";
import { themeTypes, keycloak_resources, lastKeycloakVersionWithAccountV1 } from "./constants";
import { readThisNpmProjectVersion } from "./tools/readThisNpmProjectVersion";
import { assert, type Equals } from "tsafe/assert";
import * as fs from "fs";
import { rmSync } from "./tools/fs.rmSync";
(async () => {
const projectDirPath = process.cwd();
export async function copyKeycloakResourcesToPublic(params: { processArgv: string[] }) {
const { processArgv } = params;
const buildOptions = readBuildOptions({
"processArgv": process.argv.slice(2),
"projectDirPath": process.cwd()
const buildOptions = readBuildOptions({ processArgv });
const destDirPath = pathJoin(buildOptions.publicDirPath, keycloak_resources);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
const { keycloakifyBuildinfoRaw } = generateKeycloakifyBuildinfoRaw({
destDirPath,
"keycloakifyVersion": readThisNpmProjectVersion(),
buildOptions
});
const keycloakDirInPublicDir = pathJoin(process.env["PUBLIC_DIR_PATH"] || pathJoin(projectDirPath, "public"), basenameOfKeycloakDirInPublicDir);
skip_if_already_done: {
if (!fs.existsSync(keycloakifyBuildinfoFilePath)) {
break skip_if_already_done;
}
const keycloakifyBuildinfoRaw_previousRun = fs.readFileSync(keycloakifyBuildinfoFilePath).toString("utf8");
if (keycloakifyBuildinfoRaw_previousRun !== keycloakifyBuildinfoRaw) {
break skip_if_already_done;
}
if (fs.existsSync(keycloakDirInPublicDir)) {
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} already exists.`);
return;
}
rmSync(destDirPath, { "force": true, "recursive": true });
for (const themeType of themeTypes) {
await downloadKeycloakStaticResources({
projectDirPath,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
"themeType": themeType,
"themeDirPath": keycloakDirInPublicDir,
"usedResources": undefined
"keycloakVersion": (() => {
switch (themeType) {
case "login":
return buildOptions.loginThemeResourcesFromKeycloakVersion;
case "account":
return lastKeycloakVersionWithAccountV1;
}
})(),
themeType,
"themeDirPath": destDirPath,
buildOptions
});
}
fs.writeFileSync(
pathJoin(keycloakDirInPublicDir, "README.txt"),
pathJoin(destDirPath, "README.txt"),
Buffer.from(
// prettier-ignore
[
@ -43,21 +67,46 @@ import * as fs from "fs";
)
);
fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8"));
fs.writeFileSync(pathJoin(buildOptions.publicDirPath, keycloak_resources, ".gitignore"), Buffer.from("*", "utf8"));
const buildDirPath = pathJoin(projectDirPath, "build");
fs.writeFileSync(keycloakifyBuildinfoFilePath, Buffer.from(keycloakifyBuildinfoRaw, "utf8"));
}
if (process.platform === "win32" && !fs.existsSync(buildDirPath)) {
fs.mkdirSync(buildDirPath);
}
export function generateKeycloakifyBuildinfoRaw(params: {
destDirPath: string;
keycloakifyVersion: string;
buildOptions: BuildOptionsLike & {
loginThemeResourcesFromKeycloakVersion: string;
};
}) {
const { destDirPath, keycloakifyVersion, buildOptions } = params;
try {
fs.symlinkSync(buildDirPath, pathJoin(keycloakDirInPublicDir, "resources", "build"));
} catch (error) {
if (process.platform !== "win32") {
throw error;
}
}
const { cacheDirPath, npmWorkspaceRootDirPath, loginThemeResourcesFromKeycloakVersion, ...rest } = buildOptions;
console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} directory created.`);
})();
assert<Equals<typeof rest, {}>>(true);
const keycloakifyBuildinfoRaw = JSON.stringify(
{
keycloakifyVersion,
"buildOptions": {
loginThemeResourcesFromKeycloakVersion,
"cacheDirPath": pathRelative(destDirPath, cacheDirPath),
"npmWorkspaceRootDirPath": pathRelative(destDirPath, npmWorkspaceRootDirPath)
}
},
null,
2
);
return { keycloakifyBuildinfoRaw };
}
async function main() {
await copyKeycloakResourcesToPublic({
"processArgv": process.argv.slice(2)
});
}
if (require.main === module) {
main();
}

View File

@ -1,21 +1,31 @@
#!/usr/bin/env node
import { join as pathJoin } from "path";
import { downloadAndUnzip } from "./tools/downloadAndUnzip";
import { downloadAndUnzip } from "./downloadAndUnzip";
import { promptKeycloakVersion } from "./promptKeycloakVersion";
import { getLogger } from "./tools/logger";
import { readBuildOptions } from "./keycloakify/BuildOptions";
import { readBuildOptions, type BuildOptions } from "./keycloakify/buildOptions";
import { assert } from "tsafe/assert";
import * as child_process from "child_process";
import * as fs from "fs";
import { rmSync } from "./tools/fs.rmSync";
import { lastKeycloakVersionWithAccountV1 } from "./constants";
import { transformCodebase } from "./tools/transformCodebase";
export async function downloadBuiltinKeycloakTheme(params: { projectDirPath: string; keycloakVersion: string; destDirPath: string }) {
const { projectDirPath, keycloakVersion, destDirPath } = params;
export type BuildOptionsLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: string; destDirPath: string; buildOptions: BuildOptionsLike }) {
const { keycloakVersion, destDirPath, buildOptions } = params;
await downloadAndUnzip({
"doUseCache": true,
projectDirPath,
destDirPath,
"url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`,
"specificDirsToExtract": ["", "-community"].map(ext => `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`),
buildOptions,
"preCacheTransform": {
"actionCacheId": "npm install and build",
"action": async ({ destDirPath }) => {
@ -40,32 +50,188 @@ export async function downloadBuiltinKeycloakTheme(params: { projectDirPath: str
});
}
install_and_move_to_common_resources_generated_in_keycloak_v2: {
const accountV2DirSrcDirPath = pathJoin(destDirPath, "keycloak.v2", "account", "src");
remove_keycloak_v2: {
const keycloakV2DirPath = pathJoin(destDirPath, "keycloak.v2");
if (!fs.existsSync(accountV2DirSrcDirPath)) {
break install_and_move_to_common_resources_generated_in_keycloak_v2;
if (!fs.existsSync(keycloakV2DirPath)) {
break remove_keycloak_v2;
}
child_process.execSync("npm install", { "cwd": accountV2DirSrcDirPath, "stdio": "ignore" });
rmSync(keycloakV2DirPath, { "recursive": true });
}
const packageJsonFilePath = pathJoin(accountV2DirSrcDirPath, "package.json");
// Note, this is an optimization for reducing the size of the jar
remove_unused_node_modules: {
const nodeModuleDirPath = pathJoin(destDirPath, "keycloak", "common", "resources", "node_modules");
const packageJsonRaw = fs.readFileSync(packageJsonFilePath);
if (!fs.existsSync(nodeModuleDirPath)) {
break remove_unused_node_modules;
}
const parsedPackageJson = JSON.parse(packageJsonRaw.toString("utf8"));
const toDeletePerfixes = [
"angular",
"bootstrap",
"rcue",
"font-awesome",
"ng-file-upload",
pathJoin("patternfly", "dist", "sass"),
pathJoin("patternfly", "dist", "less"),
pathJoin("patternfly", "dist", "js"),
"d3",
pathJoin("jquery", "src"),
"c3",
"core-js",
"eonasdan-bootstrap-datetimepicker",
"moment",
"react",
"patternfly-bootstrap-treeview",
"popper.js",
"tippy.js",
"jquery-match-height",
"google-code-prettify",
"patternfly-bootstrap-combobox",
"focus-trap",
"tabbable",
"scheduler",
"@types",
"datatables.net",
"datatables.net-colreorder",
"tslib",
"prop-types",
"file-selector",
"datatables.net-colreorder-bs",
"object-assign",
"warning",
"js-tokens",
"loose-envify",
"prop-types-extra",
"attr-accept",
"datatables.net-select",
"drmonty-datatables-colvis",
"datatables.net-bs",
pathJoin("@patternfly", "react"),
pathJoin("@patternfly", "patternfly", "docs")
];
parsedPackageJson.scripts.build = parsedPackageJson.scripts.build
.replace("npm run check-types", "true")
.replace("npm run babel", "true");
transformCodebase({
"srcDirPath": nodeModuleDirPath,
"destDirPath": nodeModuleDirPath,
"transformSourceCode": ({ sourceCode, fileRelativePath }) => {
if (fileRelativePath.endsWith(".map")) {
return undefined;
}
fs.writeFileSync(packageJsonFilePath, Buffer.from(JSON.stringify(parsedPackageJson, null, 2), "utf8"));
if (toDeletePerfixes.find(prefix => fileRelativePath.startsWith(prefix)) !== undefined) {
return undefined;
}
child_process.execSync("npm run build", { "cwd": accountV2DirSrcDirPath, "stdio": "ignore" });
if (fileRelativePath.startsWith(pathJoin("patternfly", "dist", "fonts"))) {
if (
!fileRelativePath.endsWith(".woff2") &&
!fileRelativePath.endsWith(".woff") &&
!fileRelativePath.endsWith(".ttf")
) {
return undefined;
}
}
fs.writeFileSync(packageJsonFilePath, packageJsonRaw);
return { "modifiedSourceCode": sourceCode };
}
});
}
fs.rmSync(pathJoin(accountV2DirSrcDirPath, "node_modules"), { "recursive": true });
// Just like node_modules
remove_unused_lib: {
const libDirPath = pathJoin(destDirPath, "keycloak", "common", "resources", "lib");
if (!fs.existsSync(libDirPath)) {
break remove_unused_lib;
}
const toDeletePerfixes = ["ui-ace", "filesaver", "fileupload", "angular", "ui-ace", "pficon"];
transformCodebase({
"srcDirPath": libDirPath,
"destDirPath": libDirPath,
"transformSourceCode": ({ sourceCode, fileRelativePath }) => {
if (fileRelativePath.endsWith(".map")) {
return undefined;
}
if (toDeletePerfixes.find(prefix => fileRelativePath.startsWith(prefix)) !== undefined) {
return undefined;
}
return { "modifiedSourceCode": sourceCode };
}
});
}
last_account_v1_transformations: {
if (lastKeycloakVersionWithAccountV1 !== keycloakVersion) {
break last_account_v1_transformations;
}
{
const accountCssFilePath = pathJoin(destDirPath, "keycloak", "account", "resources", "css", "account.css");
fs.writeFileSync(
accountCssFilePath,
Buffer.from(fs.readFileSync(accountCssFilePath).toString("utf8").replace("top: -34px;", "top: -34px !important;"), "utf8")
);
}
{
const totpFtlFilePath = pathJoin(destDirPath, "base", "account", "totp.ftl");
fs.writeFileSync(
totpFtlFilePath,
Buffer.from(
fs
.readFileSync(totpFtlFilePath)
.toString("utf8")
.replace(
[
" <#list totp.policy.supportedApplications as app>",
" <li>${app}</li>",
" </#list>"
].join("\n"),
[
" <#if totp.policy.supportedApplications?has_content>",
" <#list totp.policy.supportedApplications as app>",
" <li>${app}</li>",
" </#list>",
" </#if>"
].join("\n")
),
"utf8"
)
);
}
// Note, this is an optimization for reducing the size of the jar,
// For this version we know exactly which resources are used.
{
const nodeModulesDirPath = pathJoin(destDirPath, "keycloak", "common", "resources", "node_modules");
const toKeepPrefixes = [
...["patternfly.min.css", "patternfly-additions.min.css", "patternfly-additions.min.css"].map(fileBasename =>
pathJoin("patternfly", "dist", "css", fileBasename)
),
pathJoin("patternfly", "dist", "fonts")
];
transformCodebase({
"srcDirPath": nodeModulesDirPath,
"destDirPath": nodeModulesDirPath,
"transformSourceCode": ({ sourceCode, fileRelativePath }) => {
if (toKeepPrefixes.find(prefix => fileRelativePath.startsWith(prefix)) === undefined) {
return undefined;
}
return { "modifiedSourceCode": sourceCode };
}
});
}
}
}
}
@ -74,7 +240,6 @@ export async function downloadBuiltinKeycloakTheme(params: { projectDirPath: str
async function main() {
const buildOptions = readBuildOptions({
"projectDirPath": process.cwd(),
"processArgv": process.argv.slice(2)
});
@ -86,9 +251,9 @@ async function main() {
logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`);
await downloadBuiltinKeycloakTheme({
"projectDirPath": process.cwd(),
keycloakVersion,
destDirPath
destDirPath,
buildOptions
});
}

203
src/bin/downloadAndUnzip.ts Normal file
View File

@ -0,0 +1,203 @@
import { createHash } from "crypto";
import { mkdir, writeFile, unlink } from "fs/promises";
import fetch from "make-fetch-happen";
import { dirname as pathDirname, join as pathJoin, basename as pathBasename } from "path";
import { assert } from "tsafe/assert";
import { transformCodebase } from "./tools/transformCodebase";
import { unzip, zip } from "./tools/unzip";
import { rm } from "./tools/fs.rm";
import * as child_process from "child_process";
import { existsAsync } from "./tools/fs.existsAsync";
import type { BuildOptions } from "./keycloakify/buildOptions";
import { getProxyFetchOptions } from "./tools/fetchProxyOptions";
export type BuildOptionsLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function downloadAndUnzip(params: {
url: string;
destDirPath: string;
specificDirsToExtract?: string[];
preCacheTransform?: {
actionCacheId: string;
action: (params: { destDirPath: string }) => Promise<void>;
};
buildOptions: BuildOptionsLike;
}) {
const { url, destDirPath, specificDirsToExtract, preCacheTransform, buildOptions } = params;
const { extractDirPath, zipFilePath } = (() => {
const zipFileBasenameWithoutExt = generateFileNameFromURL({
url,
"preCacheTransform":
preCacheTransform === undefined
? undefined
: {
"actionCacheId": preCacheTransform.actionCacheId,
"actionFootprint": preCacheTransform.action.toString()
}
});
const zipFilePath = pathJoin(buildOptions.cacheDirPath, `${zipFileBasenameWithoutExt}.zip`);
const extractDirPath = pathJoin(buildOptions.cacheDirPath, `tmp_unzip_${zipFileBasenameWithoutExt}`);
return { zipFilePath, extractDirPath };
})();
download_zip_and_transform: {
if (await existsAsync(zipFilePath)) {
break download_zip_and_transform;
}
const { response, isFromRemoteCache } = await (async () => {
const proxyFetchOptions = await getProxyFetchOptions({
"npmWorkspaceRootDirPath": buildOptions.npmWorkspaceRootDirPath
});
const response = await fetch(
`https://github.com/keycloakify/keycloakify/releases/download/v0.0.1/${pathBasename(zipFilePath)}`,
proxyFetchOptions
);
if (response.status === 200) {
return {
response,
"isFromRemoteCache": true
};
}
return {
"response": await fetch(url, proxyFetchOptions),
"isFromRemoteCache": false
};
})();
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);
if (isFromRemoteCache) {
break download_zip_and_transform;
}
if (specificDirsToExtract === undefined && preCacheTransform === undefined) {
break download_zip_and_transform;
}
await unzip(zipFilePath, extractDirPath, specificDirsToExtract);
try {
await preCacheTransform?.action({
"destDirPath": extractDirPath
});
} catch (error) {
await Promise.all([rm(extractDirPath, { "recursive": true }), unlink(zipFilePath)]);
throw error;
}
await unlink(zipFilePath);
await zip(extractDirPath, zipFilePath);
await rm(extractDirPath, { "recursive": true });
upload_to_remot_cache_if_admin: {
const githubToken = process.env["KEYCLOAKIFY_ADMIN_GITHUB_PERSONAL_ACCESS_TOKEN"];
if (githubToken === undefined) {
break upload_to_remot_cache_if_admin;
}
console.log("uploading to remote cache");
try {
child_process.execSync(`which putasset`);
} catch {
child_process.execSync(`npm install -g putasset`);
}
try {
child_process.execFileSync("putasset", [
"--owner",
"keycloakify",
"--repo",
"keycloakify",
"--tag",
"v0.0.1",
"--filename",
zipFilePath,
"--token",
githubToken
]);
} catch {
console.log("upload failed, asset probably already exists in remote cache");
}
}
}
await unzip(zipFilePath, extractDirPath);
transformCodebase({
"srcDirPath": extractDirPath,
"destDirPath": destDirPath
});
await rm(extractDirPath, { "recursive": true });
}
function generateFileNameFromURL(params: {
url: string;
preCacheTransform:
| {
actionCacheId: string;
actionFootprint: string;
}
| undefined;
}): string {
const { preCacheTransform } = params;
// Parse the URL
const url = new URL(params.url);
// Extract pathname and remove leading slashes
let fileName = url.pathname.replace(/^\//, "").replace(/\//g, "_");
// Optionally, add query parameters replacing special characters
if (url.search) {
fileName += url.search.replace(/[&=?]/g, "-");
}
// Replace any characters that are not valid in filenames
fileName = fileName.replace(/[^a-zA-Z0-9-_]/g, "");
// Trim or pad the fileName to a specific length
fileName = fileName.substring(0, 50);
add_pre_cache_transform: {
if (preCacheTransform === undefined) {
break add_pre_cache_transform;
}
// Sanitize actionCacheId the same way as other components
const sanitizedActionCacheId = preCacheTransform.actionCacheId.replace(/[^a-zA-Z0-9-_]/g, "_");
fileName += `_${sanitizedActionCacheId}_${createHash("sha256").update(preCacheTransform.actionFootprint).digest("hex").substring(0, 5)}`;
}
return fileName;
}

View File

@ -1,26 +1,25 @@
#!/usr/bin/env node
import { getProjectRoot } from "./tools/getProjectRoot";
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
import cliSelect from "cli-select";
import {
loginThemePageIds,
accountThemePageIds,
type LoginThemePageId,
type AccountThemePageId,
themeTypes,
type ThemeType
} from "./keycloakify/generateFtl";
import { loginThemePageIds, accountThemePageIds, type LoginThemePageId, type AccountThemePageId } from "./keycloakify/generateFtl";
import { capitalize } from "tsafe/capitalize";
import { readFile, writeFile } from "fs/promises";
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 "./getSrcDirPath";
import { getThemeSrcDirPath } from "./getThemeSrcDirPath";
import { themeTypes, type ThemeType } from "./constants";
import { getReactAppRootDirPath } from "./keycloakify/buildOptions/getReactAppRootDirPath";
(async () => {
console.log("Select a theme type");
const { reactAppRootDirPath } = getReactAppRootDirPath({
"processArgv": process.argv.slice(2)
});
const { value: themeType } = await cliSelect<ThemeType>({
"values": [...themeTypes]
}).catch(() => {
@ -49,7 +48,7 @@ import { getThemeSrcDirPath } from "./getSrcDirPath";
const pageBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(/ftl$/, "tsx");
const { themeSrcDirPath } = getThemeSrcDirPath({ "projectDirPath": process.cwd() });
const { themeSrcDirPath } = getThemeSrcDirPath({ reactAppRootDirPath });
const targetFilePath = pathJoin(themeSrcDirPath, themeType, "pages", pageBasename);
@ -59,7 +58,7 @@ import { getThemeSrcDirPath } from "./getSrcDirPath";
process.exit(-1);
}
await writeFile(targetFilePath, await readFile(pathJoin(getProjectRoot(), "src", themeType, "pages", pageBasename)));
await writeFile(targetFilePath, await readFile(pathJoin(getThisCodebaseRootDirPath(), "src", themeType, "pages", pageBasename)));
console.log(`${pathRelative(process.cwd(), targetFilePath)} created`);
})();

View File

@ -2,15 +2,15 @@ import * as fs from "fs";
import { exclude } from "tsafe";
import { crawl } from "./tools/crawl";
import { join as pathJoin } from "path";
import { themeTypes } from "./keycloakify/generateFtl";
import { themeTypes } from "./constants";
const themeSrcDirBasenames = ["keycloak-theme", "keycloak_theme"];
/** Can't catch error, if the directory isn't found, this function will just exit the process with an error message. */
export function getThemeSrcDirPath(params: { projectDirPath: string }) {
const { projectDirPath } = params;
export function getThemeSrcDirPath(params: { reactAppRootDirPath: string }) {
const { reactAppRootDirPath } = params;
const srcDirPath = pathJoin(projectDirPath, "src");
const srcDirPath = pathJoin(reactAppRootDirPath, "src");
const themeSrcDirPath: string | undefined = crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })
.map(fileRelativePath => {

View File

@ -4,23 +4,21 @@ import { downloadBuiltinKeycloakTheme } from "./download-builtin-keycloak-theme"
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./promptKeycloakVersion";
import { readBuildOptions } from "./keycloakify/BuildOptions";
import { readBuildOptions } from "./keycloakify/buildOptions";
import * as fs from "fs";
import { getLogger } from "./tools/logger";
import { getThemeSrcDirPath } from "./getSrcDirPath";
import { getThemeSrcDirPath } from "./getThemeSrcDirPath";
import { rmSync } from "./tools/fs.rmSync";
export async function main() {
const projectDirPath = process.cwd();
const { isSilent } = readBuildOptions({
projectDirPath,
const buildOptions = readBuildOptions({
"processArgv": process.argv.slice(2)
});
const logger = getLogger({ isSilent });
const logger = getLogger({ "isSilent": buildOptions.isSilent });
const { themeSrcDirPath } = getThemeSrcDirPath({
projectDirPath
"reactAppRootDirPath": buildOptions.reactAppRootDirPath
});
const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email");
@ -36,9 +34,9 @@ export async function main() {
const builtinKeycloakThemeTmpDirPath = pathJoin(emailThemeSrcDirPath, "..", "tmp_xIdP3_builtin_keycloak_theme");
await downloadBuiltinKeycloakTheme({
projectDirPath,
keycloakVersion,
"destDirPath": builtinKeycloakThemeTmpDirPath
"destDirPath": builtinKeycloakThemeTmpDirPath,
buildOptions
});
transformCodebase({
@ -54,7 +52,7 @@ export async function main() {
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 });
rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true, "force": true });
}
if (require.main === module) {

View File

@ -1,138 +0,0 @@
import { assert } from "tsafe/assert";
import { id } from "tsafe/id";
import { parse as urlParse } from "url";
import { typeGuard } from "tsafe/typeGuard";
import { symToStr } from "tsafe/symToStr";
import { bundlers, getParsedPackageJson, type Bundler } from "./parsedPackageJson";
import { join as pathJoin, sep as pathSep } from "path";
import parseArgv from "minimist";
/** Consolidated build option gathered form CLI arguments and config in package.json */
export type BuildOptions = {
isSilent: boolean;
themeVersion: string;
themeName: string;
extraThemeNames: string[];
extraThemeProperties: string[] | undefined;
groupId: string;
artifactId: string;
bundler: Bundler;
keycloakVersionDefaultAssets: string;
/** Directory of your built react project. Defaults to {cwd}/build */
reactAppBuildDirPath: string;
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
keycloakifyBuildDirPath: string;
/** If your app is hosted under a subpath, it's the case in CRA if you have "homepage": "https://example.com/my-app" in your package.json
* In this case the urlPathname will be "/my-app/" */
urlPathname: string | undefined;
};
export function readBuildOptions(params: { projectDirPath: string; processArgv: string[] }): BuildOptions {
const { projectDirPath, processArgv } = params;
const { isSilentCliParamProvided } = (() => {
const argv = parseArgv(processArgv);
return {
"isSilentCliParamProvided": typeof argv["silent"] === "boolean" ? argv["silent"] : false
};
})();
const parsedPackageJson = getParsedPackageJson({ projectDirPath });
const { name, keycloakify = {}, version, homepage } = parsedPackageJson;
const { extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets, extraThemeNames = [] } = keycloakify ?? {};
const themeName =
keycloakify.themeName ??
name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-");
return {
themeName,
extraThemeNames,
"bundler": (() => {
const { KEYCLOAKIFY_BUNDLER } = process.env;
assert(
typeGuard<Bundler | undefined>(KEYCLOAKIFY_BUNDLER, [undefined, ...id<readonly string[]>(bundlers)].includes(KEYCLOAKIFY_BUNDLER)),
`${symToStr({ KEYCLOAKIFY_BUNDLER })} should be one of ${bundlers.join(", ")}`
);
return KEYCLOAKIFY_BUNDLER ?? bundler ?? "keycloakify";
})(),
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? artifactId ?? `${themeName}-keycloak-theme`,
"groupId": (() => {
const fallbackGroupId = `${themeName}.keycloak`;
return (
process.env.KEYCLOAKIFY_GROUP_ID ??
groupId ??
(!homepage
? fallbackGroupId
: urlParse(homepage)
.host?.replace(/:[0-9]+$/, "")
?.split(".")
.reverse()
.join(".") ?? fallbackGroupId) + ".keycloak"
);
})(),
"themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? process.env.KEYCLOAKIFY_VERSION ?? version ?? "0.0.0",
extraThemeProperties,
"isSilent": isSilentCliParamProvided,
"keycloakVersionDefaultAssets": keycloakVersionDefaultAssets ?? "11.0.3",
"reactAppBuildDirPath": (() => {
let { reactAppBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {};
if (reactAppBuildDirPath === undefined) {
return pathJoin(projectDirPath, "build");
}
if (pathSep === "\\") {
reactAppBuildDirPath = reactAppBuildDirPath.replace(/\//g, pathSep);
}
if (reactAppBuildDirPath.startsWith(`.${pathSep}`)) {
return pathJoin(projectDirPath, reactAppBuildDirPath);
}
return reactAppBuildDirPath;
})(),
"keycloakifyBuildDirPath": (() => {
let { keycloakifyBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {};
if (keycloakifyBuildDirPath === undefined) {
return pathJoin(projectDirPath, "build_keycloak");
}
if (pathSep === "\\") {
keycloakifyBuildDirPath = keycloakifyBuildDirPath.replace(/\//g, pathSep);
}
if (keycloakifyBuildDirPath.startsWith(`.${pathSep}`)) {
return pathJoin(projectDirPath, keycloakifyBuildDirPath);
}
return keycloakifyBuildDirPath;
})(),
"urlPathname": (() => {
const { homepage } = parsedPackageJson;
let url: URL | undefined = undefined;
if (homepage !== undefined) {
url = new URL(homepage);
}
if (url === undefined) {
return undefined;
}
const out = url.pathname.replace(/([^/])$/, "$1/");
return out === "/" ? undefined : out;
})()
};
}

View File

@ -0,0 +1,25 @@
import { z } from "zod";
export type UserProvidedBuildOptions = {
extraThemeProperties?: string[];
artifactId?: string;
groupId?: string;
doCreateJar?: boolean;
loginThemeResourcesFromKeycloakVersion?: string;
reactAppBuildDirPath?: string;
keycloakifyBuildDirPath?: string;
themeName?: string | string[];
doBuildRetrocompatAccountTheme?: boolean;
};
export const zUserProvidedBuildOptions = z.object({
"extraThemeProperties": z.array(z.string()).optional(),
"artifactId": z.string().optional(),
"groupId": z.string().optional(),
"doCreateJar": z.boolean().optional(),
"loginThemeResourcesFromKeycloakVersion": z.string().optional(),
"reactAppBuildDirPath": z.string().optional(),
"keycloakifyBuildDirPath": z.string().optional(),
"themeName": z.union([z.string(), z.array(z.string())]).optional(),
"doBuildRetrocompatAccountTheme": z.boolean().optional()
});

View File

@ -0,0 +1,193 @@
import { parse as urlParse } from "url";
import { readParsedPackageJson } from "./parsedPackageJson";
import { join as pathJoin } from "path";
import parseArgv from "minimist";
import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath";
import { readResolvedViteConfig } from "./resolvedViteConfig";
import * as fs from "fs";
import { getCacheDirPath } from "./getCacheDirPath";
import { getReactAppRootDirPath } from "./getReactAppRootDirPath";
import { getNpmWorkspaceRootDirPath } from "./getNpmWorkspaceRootDirPath";
/** Consolidated build option gathered form CLI arguments and config in package.json */
export type BuildOptions = {
bundler: "vite" | "webpack";
isSilent: boolean;
themeVersion: string;
themeNames: string[];
extraThemeProperties: string[] | undefined;
groupId: string;
artifactId: string;
doCreateJar: boolean;
loginThemeResourcesFromKeycloakVersion: string;
reactAppRootDirPath: string;
reactAppBuildDirPath: string;
/** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */
keycloakifyBuildDirPath: string;
publicDirPath: string;
cacheDirPath: string;
/** If your app is hosted under a subpath, it's the case in CRA if you have "homepage": "https://example.com/my-app" in your package.json
* In this case the urlPathname will be "/my-app/" */
urlPathname: string | undefined;
assetsDirPath: string;
doBuildRetrocompatAccountTheme: boolean;
npmWorkspaceRootDirPath: string;
};
export function readBuildOptions(params: { processArgv: string[] }): BuildOptions {
const { processArgv } = params;
const { reactAppRootDirPath } = getReactAppRootDirPath({ processArgv });
const { cacheDirPath } = getCacheDirPath({ reactAppRootDirPath });
const { resolvedViteConfig } = readResolvedViteConfig({ cacheDirPath });
if (resolvedViteConfig === undefined && fs.existsSync(pathJoin(reactAppRootDirPath, "vite.config.ts"))) {
throw new Error("Keycloakify's Vite plugin output not found");
}
const { keycloakify: userProvidedBuildOptionsFromPackageJson, ...parsedPackageJson } = readParsedPackageJson({ reactAppRootDirPath });
const userProvidedBuildOptions = {
...userProvidedBuildOptionsFromPackageJson,
...resolvedViteConfig?.userProvidedBuildOptions
};
const themeNames = (() => {
if (userProvidedBuildOptions.themeName === undefined) {
return [
parsedPackageJson.name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-")
];
}
if (typeof userProvidedBuildOptions.themeName === "string") {
return [userProvidedBuildOptions.themeName];
}
return userProvidedBuildOptions.themeName;
})();
const reactAppBuildDirPath = (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
if (userProvidedBuildOptions.reactAppBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
"pathIsh": userProvidedBuildOptions.reactAppBuildDirPath,
"cwd": reactAppRootDirPath
});
}
return pathJoin(reactAppRootDirPath, "build");
}
return pathJoin(reactAppRootDirPath, resolvedViteConfig.buildDir);
})();
const argv = parseArgv(processArgv);
const { npmWorkspaceRootDirPath } = getNpmWorkspaceRootDirPath({ reactAppRootDirPath });
return {
"bundler": resolvedViteConfig !== undefined ? "vite" : "webpack",
"isSilent": typeof argv["silent"] === "boolean" ? argv["silent"] : false,
"themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? parsedPackageJson.version ?? "0.0.0",
themeNames,
"extraThemeProperties": userProvidedBuildOptions.extraThemeProperties,
"groupId": (() => {
const fallbackGroupId = `${themeNames[0]}.keycloak`;
return (
process.env.KEYCLOAKIFY_GROUP_ID ??
userProvidedBuildOptions.groupId ??
(parsedPackageJson.homepage === undefined
? fallbackGroupId
: urlParse(parsedPackageJson.homepage)
.host?.replace(/:[0-9]+$/, "")
?.split(".")
.reverse()
.join(".") ?? fallbackGroupId) + ".keycloak"
);
})(),
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? userProvidedBuildOptions.artifactId ?? `${themeNames[0]}-keycloak-theme`,
"doCreateJar": userProvidedBuildOptions.doCreateJar ?? true,
"loginThemeResourcesFromKeycloakVersion": userProvidedBuildOptions.loginThemeResourcesFromKeycloakVersion ?? "11.0.3",
reactAppRootDirPath,
reactAppBuildDirPath,
"keycloakifyBuildDirPath": (() => {
if (userProvidedBuildOptions.keycloakifyBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
"pathIsh": userProvidedBuildOptions.keycloakifyBuildDirPath,
"cwd": reactAppRootDirPath
});
}
return pathJoin(
reactAppRootDirPath,
resolvedViteConfig?.buildDir === undefined ? "build_keycloak" : `${resolvedViteConfig.buildDir}_keycloak`
);
})(),
"publicDirPath": (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
if (process.env.PUBLIC_DIR_PATH !== undefined) {
return getAbsoluteAndInOsFormatPath({
"pathIsh": process.env.PUBLIC_DIR_PATH,
"cwd": reactAppRootDirPath
});
}
return pathJoin(reactAppRootDirPath, "public");
}
return pathJoin(reactAppRootDirPath, resolvedViteConfig.publicDir);
})(),
cacheDirPath,
"urlPathname": (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
const { homepage } = parsedPackageJson;
let url: URL | undefined = undefined;
if (homepage !== undefined) {
url = new URL(homepage);
}
if (url === undefined) {
return undefined;
}
const out = url.pathname.replace(/([^/])$/, "$1/");
return out === "/" ? undefined : out;
}
return resolvedViteConfig.urlPathname;
})(),
"assetsDirPath": (() => {
webpack: {
if (resolvedViteConfig !== undefined) {
break webpack;
}
return pathJoin(reactAppBuildDirPath, "static");
}
return pathJoin(reactAppBuildDirPath, resolvedViteConfig.assetsDir);
})(),
"doBuildRetrocompatAccountTheme": userProvidedBuildOptions.doBuildRetrocompatAccountTheme ?? true,
npmWorkspaceRootDirPath
};
}

View File

@ -0,0 +1,25 @@
import { join as pathJoin } from "path";
import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath";
import { getNpmWorkspaceRootDirPath } from "./getNpmWorkspaceRootDirPath";
export function getCacheDirPath(params: { reactAppRootDirPath: string }) {
const { reactAppRootDirPath } = params;
const { npmWorkspaceRootDirPath } = getNpmWorkspaceRootDirPath({ reactAppRootDirPath });
const cacheDirPath = pathJoin(
(() => {
if (process.env.XDG_CACHE_HOME !== undefined) {
return getAbsoluteAndInOsFormatPath({
"pathIsh": process.env.XDG_CACHE_HOME,
"cwd": reactAppRootDirPath
});
}
return pathJoin(npmWorkspaceRootDirPath, "node_modules", ".cache");
})(),
"keycloakify"
);
return { cacheDirPath };
}

View File

@ -0,0 +1,49 @@
import * as child_process from "child_process";
import { join as pathJoin, resolve as pathResolve, sep as pathSep } from "path";
import { assert } from "tsafe/assert";
let cache:
| {
reactAppRootDirPath: string;
npmWorkspaceRootDirPath: string;
}
| undefined = undefined;
export function getNpmWorkspaceRootDirPath(params: { reactAppRootDirPath: string }) {
const { reactAppRootDirPath } = params;
use_cache: {
if (cache === undefined || cache.reactAppRootDirPath !== reactAppRootDirPath) {
break use_cache;
}
const { npmWorkspaceRootDirPath } = cache;
return { npmWorkspaceRootDirPath };
}
const npmWorkspaceRootDirPath = (function callee(depth: number): string {
const cwd = pathResolve(pathJoin(...[reactAppRootDirPath, ...Array(depth).fill("..")]));
try {
child_process.execSync("npm config get", { cwd: cwd });
} catch (error) {
if (String(error).includes("ENOWORKSPACES")) {
assert(cwd !== pathSep, "NPM workspace not found");
return callee(depth + 1);
}
throw error;
}
return cwd;
})(0);
cache = {
reactAppRootDirPath,
npmWorkspaceRootDirPath
};
return { npmWorkspaceRootDirPath };
}

View File

@ -0,0 +1,23 @@
import parseArgv from "minimist";
import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath";
export function getReactAppRootDirPath(params: { processArgv: string[] }) {
const { processArgv } = params;
const argv = parseArgv(processArgv);
const reactAppRootDirPath = (() => {
const arg = argv["project"] ?? argv["p"];
if (typeof arg !== "string") {
return process.cwd();
}
return getAbsoluteAndInOsFormatPath({
"pathIsh": arg,
"cwd": process.cwd()
});
})();
return { reactAppRootDirPath };
}

View File

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

View File

@ -0,0 +1,32 @@
import * as fs from "fs";
import { assert } from "tsafe";
import type { Equals } from "tsafe";
import { z } from "zod";
import { join as pathJoin } from "path";
import { type UserProvidedBuildOptions, zUserProvidedBuildOptions } from "./UserProvidedBuildOptions";
export type ParsedPackageJson = {
name: string;
version?: string;
homepage?: string;
keycloakify?: UserProvidedBuildOptions;
};
const zParsedPackageJson = z.object({
"name": z.string(),
"version": z.string().optional(),
"homepage": z.string().optional(),
"keycloakify": zUserProvidedBuildOptions.optional()
});
assert<Equals<ReturnType<(typeof zParsedPackageJson)["parse"]>, ParsedPackageJson>>();
let parsedPackageJson: undefined | ParsedPackageJson;
export function readParsedPackageJson(params: { reactAppRootDirPath: string }) {
const { reactAppRootDirPath } = params;
if (parsedPackageJson) {
return parsedPackageJson;
}
parsedPackageJson = zParsedPackageJson.parse(JSON.parse(fs.readFileSync(pathJoin(reactAppRootDirPath, "package.json")).toString("utf8")));
return parsedPackageJson;
}

View File

@ -0,0 +1,74 @@
import * as fs from "fs";
import { assert } from "tsafe";
import type { Equals } from "tsafe";
import { z } from "zod";
import { join as pathJoin } from "path";
import { resolvedViteConfigJsonBasename } from "../../constants";
import type { OptionalIfCanBeUndefined } from "../../tools/OptionalIfCanBeUndefined";
import { UserProvidedBuildOptions, zUserProvidedBuildOptions } from "./UserProvidedBuildOptions";
export type ResolvedViteConfig = {
buildDir: string;
publicDir: string;
assetsDir: string;
urlPathname: string | undefined;
userProvidedBuildOptions: UserProvidedBuildOptions;
};
const zResolvedViteConfig = z.object({
"buildDir": z.string(),
"publicDir": z.string(),
"assetsDir": z.string(),
"urlPathname": z.string().optional(),
"userProvidedBuildOptions": zUserProvidedBuildOptions
});
{
type Got = ReturnType<(typeof zResolvedViteConfig)["parse"]>;
type Expected = OptionalIfCanBeUndefined<ResolvedViteConfig>;
assert<Equals<Got, Expected>>();
}
export function readResolvedViteConfig(params: { cacheDirPath: string }): {
resolvedViteConfig: ResolvedViteConfig | undefined;
} {
const { cacheDirPath } = params;
const resolvedViteConfigJsonFilePath = pathJoin(cacheDirPath, resolvedViteConfigJsonBasename);
if (!fs.existsSync(resolvedViteConfigJsonFilePath)) {
return { "resolvedViteConfig": undefined };
}
const resolvedViteConfig = (() => {
if (!fs.existsSync(resolvedViteConfigJsonFilePath)) {
throw new Error("Missing Keycloakify Vite plugin output.");
}
let out: ResolvedViteConfig;
try {
out = JSON.parse(fs.readFileSync(resolvedViteConfigJsonFilePath).toString("utf8"));
} catch {
throw new Error("The output of the Keycloakify Vite plugin is not a valid JSON.");
}
try {
const zodParseReturn = zResolvedViteConfig.parse(out);
// So that objectKeys from tsafe return the expected result no matter what.
Object.keys(zodParseReturn)
.filter(key => !(key in out))
.forEach(key => {
delete (out as any)[key];
});
} catch {
throw new Error("The output of the Keycloakify Vite plugin do not match the expected schema.");
}
return out;
})();
return { resolvedViteConfig };
}

View File

@ -1 +0,0 @@
export const ftlValuesGlobalName = "kcContext";

View File

@ -408,6 +408,14 @@
out["themeName"] = "KEYCLOAKIFY_THEME_NAME_cXxKd3xEer";
out["pageId"] = "${pageId}";
try {
out["url"]["resourcesCommonPath"] = out["url"]["resourcesPath"] + "/" + "RESOURCES_COMMON_cLsLsMrtDkpVv";
} catch(error) {
}
return out;
})()
@ -423,7 +431,7 @@
<#if isHash>
<#if path?size gt 10>
<#return "ABORT: Too many recursive calls">
<#return "ABORT: Too many recursive calls, path: " + path?join(".")>
</#if>
<#local keys = "">
@ -455,9 +463,10 @@
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
key == "loginAction" &&
are_same_path(path, ["url"]) &&
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl"]?seq_contains(pageId) &&
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
!(auth?has_content && auth.showTryAnotherWayLink())
) || (
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
@ -480,24 +489,33 @@
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
) || (
"applications.ftl" == pageId &&
are_same_path(path, ["applications", "applications", "*", "client", "realm"])
is_subpath(path, ["applications", "applications"]) &&
(
key == "realm" ||
key == "container"
)
) || (
"applications.ftl" == pageId &&
"masterAdminClient" == key
are_same_path(path, ["user"]) &&
key == "delegateForUpdate"
)
>
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#local out_seq += ["/*If you need '" + path?join(".") + "." + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#continue>
</#if>
<#if pageId == "register.ftl" && key == "attemptedUsername" && are_same_path(path, ["auth"])>
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
<#if (
["register.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
key == "attemptedUsername" && are_same_path(path, ["auth"])
)>
<#attempt>
<#-- https://github.com/keycloak/keycloak/blob/3a2bf0c04bcde185e497aaa32d0bb7ab7520cf4a/themes/src/main/resources/theme/base/login/template.ftl#L63 -->
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
<#continue>
</#if>
<#recover>
<#local out_seq += ["/*Testing if attemptedUsername should be skipped throwed an exception */"]>
</#attempt>
</#if>
@ -650,9 +668,9 @@
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non enumerable object">
</#function>
<#function are_same_path path searchedPath>
<#function is_subpath path searchedPath>
<#if path?size != searchedPath?size>
<#if path?size < searchedPath?size>
<#return false>
</#if>
@ -660,8 +678,14 @@
<#list path as property>
<#if i == searchedPath?size >
<#continue>
</#if>
<#local searchedProperty=searchedPath[i]>
<#local i+= 1>
<#if searchedProperty?is_string && searchedProperty == "*">
<#continue>
</#if>
@ -678,11 +702,13 @@
<#return false>
</#if>
<#local i+= 1>
</#list>
<#return true>
</#function>
<#function are_same_path path searchedPath>
<#return path?size == searchedPath?size && is_subpath(path, searchedPath)>
</#function>
</script>

View File

@ -1,36 +1,34 @@
import cheerio from "cheerio";
import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { generateCssCodeToDefineGlobals } from "../replacers/replaceImportsInCssCode";
import { replaceImportsInInlineCssCode } from "../replacers/replaceImportsInInlineCssCode";
import * as fs from "fs";
import { join as pathJoin } from "path";
import { objectKeys } from "tsafe/objectKeys";
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
import type { BuildOptions } from "../BuildOptions";
import type { BuildOptions } from "../buildOptions";
import { assert } from "tsafe/assert";
export const themeTypes = ["login", "account"] as const;
export type ThemeType = (typeof themeTypes)[number];
import { type ThemeType, nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir, resources_common } from "../../constants";
export type BuildOptionsLike = {
themeName: string;
bundler: "vite" | "webpack";
themeVersion: string;
urlPathname: string | undefined;
reactAppBuildDirPath: string;
assetsDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function generateFtlFilesCodeFactory(params: {
themeName: string;
indexHtmlCode: string;
//NOTE: Expected to be an empty object if external assets mode is enabled.
cssGlobalsToDefine: Record<string, string>;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
themeType: ThemeType;
fieldNames: string[];
}) {
const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType, fieldNames } = params;
const { themeName, cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType, fieldNames } = params;
const $ = cheerio.load(indexHtmlCode);
@ -40,7 +38,7 @@ export function generateFtlFilesCodeFactory(params: {
assert(jsCode !== null);
const { fixedJsCode } = replaceImportsFromStaticInJsCode({ jsCode });
const { fixedJsCode } = replaceImportsInJsCode({ jsCode, buildOptions });
$(element).text(fixedJsCode);
});
@ -73,7 +71,10 @@ export function generateFtlFilesCodeFactory(params: {
$(element).attr(
attrName,
href.replace(new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`), "${url.resourcesPath}/build/")
href.replace(
new RegExp(`^${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`),
`\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
)
);
})
);
@ -104,7 +105,8 @@ export function generateFtlFilesCodeFactory(params: {
.replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion)
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion)
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", buildOptions.themeName),
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", themeName)
.replace("RESOURCES_COMMON_cLsLsMrtDkpVv", resources_common),
"<!-- xIdLqMeOedErIdLsPdNdI9dSlxI -->": [
"<#if scripts??>",
" <#list scripts as script>",
@ -117,13 +119,34 @@ export function generateFtlFilesCodeFactory(params: {
$("head").prepend(
[
"<script>",
` window.${ftlValuesGlobalName}= ${objectKeys(replaceValueBySearchValue)[0]};`,
` window.${nameOfTheGlobal}= ${objectKeys(replaceValueBySearchValue)[0]};`,
"</script>",
"",
objectKeys(replaceValueBySearchValue)[1]
].join("\n")
);
// Remove part of the document marked as ignored.
{
const startTags = $('meta[name="keycloakify-ignore-start"]');
startTags.each((...[, startTag]) => {
const $startTag = $(startTag);
const $endTag = $startTag.nextAll('meta[name="keycloakify-ignore-end"]').first();
if ($endTag.length) {
let currentNode = $startTag.next();
while (currentNode.length && !currentNode.is($endTag)) {
currentNode.remove();
currentNode = $startTag.next();
}
$startTag.remove();
$endTag.remove();
}
});
}
const partiallyFixedIndexHtmlCode = $.html();
function generateFtlFilesCode(params: { pageId: string }): {

View File

@ -1,84 +0,0 @@
import * as fs from "fs";
import { join as pathJoin, dirname as pathDirname } from "path";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "./BuildOptions";
import type { ThemeType } from "./generateFtl";
export type BuildOptionsLike = {
themeName: string;
extraThemeNames: string[];
groupId: string;
artifactId: string;
themeVersion: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function generateJavaStackFiles(params: {
keycloakThemeBuildingDirPath: string;
implementedThemeTypes: Record<ThemeType | "email", boolean>;
buildOptions: BuildOptionsLike;
}): {
jarFilePath: string;
} {
const {
buildOptions: { groupId, themeName, extraThemeNames, themeVersion, artifactId },
keycloakThemeBuildingDirPath,
implementedThemeTypes
} = params;
{
const { pomFileCode } = (function generatePomFileCode(): {
pomFileCode: string;
} {
const pomFileCode = [
`<?xml version="1.0"?>`,
`<project xmlns="http://maven.apache.org/POM/4.0.0"`,
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
` <modelVersion>4.0.0</modelVersion>`,
` <groupId>${groupId}</groupId>`,
` <artifactId>${artifactId}</artifactId>`,
` <version>${themeVersion}</version>`,
` <name>${artifactId}</name>`,
` <description />`,
`</project>`
].join("\n");
return { pomFileCode };
})();
fs.writeFileSync(pathJoin(keycloakThemeBuildingDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8"));
}
{
const themeManifestFilePath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "META-INF", "keycloak-themes.json");
try {
fs.mkdirSync(pathDirname(themeManifestFilePath));
} catch {}
fs.writeFileSync(
themeManifestFilePath,
Buffer.from(
JSON.stringify(
{
"themes": [themeName, ...extraThemeNames].map(themeName => ({
"name": themeName,
"types": Object.entries(implementedThemeTypes)
.filter(([, isImplemented]) => isImplemented)
.map(([themeType]) => themeType)
}))
},
null,
2
),
"utf8"
)
);
}
return {
"jarFilePath": pathJoin(keycloakThemeBuildingDirPath, "target", `${artifactId}-${themeVersion}.jar`)
};
}

View File

@ -0,0 +1,70 @@
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "./buildOptions";
type BuildOptionsLike = {
groupId: string;
artifactId: string;
themeVersion: string;
keycloakifyBuildDirPath: string;
};
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export function generatePom(params: { buildOptions: BuildOptionsLike }) {
const { buildOptions } = params;
const { pomFileCode } = (function generatePomFileCode(): {
pomFileCode: string;
} {
const pomFileCode = [
`<?xml version="1.0"?>`,
`<project xmlns="http://maven.apache.org/POM/4.0.0"`,
` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
` xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">`,
` <modelVersion>4.0.0</modelVersion>`,
` <groupId>${buildOptions.groupId}</groupId>`,
` <artifactId>${buildOptions.artifactId}</artifactId>`,
` <version>${buildOptions.themeVersion}</version>`,
` <name>${buildOptions.artifactId}</name>`,
` <description />`,
` <packaging>jar</packaging>`,
` <properties>`,
` <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>`,
` </properties>`,
` <build>`,
` <plugins>`,
` <plugin>`,
` <groupId>org.apache.maven.plugins</groupId>`,
` <artifactId>maven-shade-plugin</artifactId>`,
` <version>3.5.1</version>`,
` <executions>`,
` <execution>`,
` <phase>package</phase>`,
` <goals>`,
` <goal>shade</goal>`,
` </goals>`,
` </execution>`,
` </executions>`,
` </plugin>`,
` </plugins>`,
` </build>`,
` <dependencies>`,
` <dependency>`,
` <groupId>io.phasetwo.keycloak</groupId>`,
` <artifactId>keycloak-account-v1</artifactId>`,
` <version>0.1</version>`,
` </dependency>`,
` </dependencies>`,
`</project>`
].join("\n");
return { pomFileCode };
})();
return { pomFileCode };
}

View File

@ -1,53 +1,59 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { join as pathJoin, relative as pathRelative, basename as pathBasename } from "path";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "./BuildOptions";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "./buildOptions";
export type BuildOptionsLike = {
themeName: string;
extraThemeNames: string[];
keycloakifyBuildDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh";
const containerName = "keycloak-testing-container";
/** Files for being able to run a hot reload keycloak container */
export function generateStartKeycloakTestingContainer(params: {
keycloakVersion: string;
keycloakThemeBuildingDirPath: string;
buildOptions: BuildOptionsLike;
}) {
const {
keycloakThemeBuildingDirPath,
keycloakVersion,
buildOptions: { themeName, extraThemeNames }
} = params;
export function generateStartKeycloakTestingContainer(params: { jarFilePath: string; keycloakVersion: string; buildOptions: BuildOptionsLike }) {
const { jarFilePath, keycloakVersion, buildOptions } = params;
const themeRelativeDirPath = pathJoin("src", "main", "resources", "theme");
const themeDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, themeRelativeDirPath);
fs.writeFileSync(
pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename),
pathJoin(buildOptions.keycloakifyBuildDirPath, generateStartKeycloakTestingContainer.basename),
Buffer.from(
[
"#!/usr/bin/env bash",
"",
`docker rm ${containerName} || true`,
"",
`cd "${keycloakThemeBuildingDirPath.replace(/\\/g, "/")}"`,
`cd "${buildOptions.keycloakifyBuildDirPath}"`,
"",
"docker run \\",
" -p 8080:8080 \\",
` --name ${containerName} \\`,
" -e KEYCLOAK_ADMIN=admin \\",
" -e KEYCLOAK_ADMIN_PASSWORD=admin \\",
...[themeName, ...extraThemeNames].map(
themeName =>
` -v "${pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace(
/\\/g,
"/"
)}":"/opt/keycloak/themes/${themeName}":rw \\`
),
` -v "${pathJoin(
"$(pwd)",
pathRelative(buildOptions.keycloakifyBuildDirPath, jarFilePath)
)}":"/opt/keycloak/providers/${pathBasename(jarFilePath)}" \\`,
...fs
.readdirSync(themeDirPath)
.filter(name => fs.lstatSync(pathJoin(themeDirPath, name)).isDirectory())
.map(
themeName =>
` -v "${pathJoin("$(pwd)", themeRelativeDirPath, themeName).replace(
/\\/g,
"/"
)}":"/opt/keycloak/themes/${themeName}":rw \\`
),
` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`,
` start-dev --features=declarative-user-profile`,
""

View File

@ -0,0 +1,84 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import { Reflect } from "tsafe/Reflect";
import type { BuildOptions } from "../buildOptions";
import { resources_common, lastKeycloakVersionWithAccountV1, accountV1ThemeName } from "../../constants";
import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme";
import { transformCodebase } from "../../tools/transformCodebase";
import { rmSync } from "../../tools/fs.rmSync";
type BuildOptionsLike = {
keycloakifyBuildDirPath: string;
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
{
const buildOptions = Reflect<BuildOptions>();
assert<typeof buildOptions extends BuildOptionsLike ? true : false>();
}
export async function bringInAccountV1(params: { buildOptions: BuildOptionsLike }) {
const { buildOptions } = params;
const builtinKeycloakThemeTmpDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "..", "tmp_yxdE2_builtin_keycloak_theme");
await downloadBuiltinKeycloakTheme({
"destDirPath": builtinKeycloakThemeTmpDirPath,
"keycloakVersion": lastKeycloakVersionWithAccountV1,
buildOptions
});
const accountV1DirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources", "theme", accountV1ThemeName, "account");
transformCodebase({
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "base", "account"),
"destDirPath": accountV1DirPath
});
transformCodebase({
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "keycloak", "account", "resources"),
"destDirPath": pathJoin(accountV1DirPath, "resources")
});
transformCodebase({
"srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "keycloak", "common", "resources"),
"destDirPath": pathJoin(accountV1DirPath, "resources", resources_common)
});
rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true });
fs.writeFileSync(
pathJoin(accountV1DirPath, "theme.properties"),
Buffer.from(
[
"accountResourceProvider=account-v1",
"",
"locales=ar,ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN",
"",
"styles=" +
[
"css/account.css",
"img/icon-sidebar-active.png",
"img/logo.png",
...["patternfly.min.css", "patternfly-additions.min.css", "patternfly-additions.min.css"].map(
fileBasename => `${resources_common}/node_modules/patternfly/dist/css/${fileBasename}`
)
].join(" "),
"",
"##### css classes for form buttons",
"# main class used for all buttons",
"kcButtonClass=btn",
"# classes defining priority of the button - primary or default (there is typically only one priority button for the form)",
"kcButtonPrimaryClass=btn-primary",
"kcButtonDefaultClass=btn-default",
"# classes defining size of the button",
"kcButtonLargeClass=btn-lg",
""
].join("\n"),
"utf8"
)
);
}

View File

@ -1,86 +1,49 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import type { ThemeType } from "../generateFtl";
import { join as pathJoin } from "path";
import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme";
import {
resourcesCommonDirPathRelativeToPublicDir,
resourcesDirPathRelativeToPublicDir,
basenameOfKeycloakDirInPublicDir
} from "../../mockTestingResourcesPath";
import * as crypto from "crypto";
import { resources_common, type ThemeType } from "../../constants";
import { BuildOptions } from "../buildOptions";
import { assert } from "tsafe/assert";
import * as crypto from "crypto";
import { rmSync } from "../../tools/fs.rmSync";
export async function downloadKeycloakStaticResources(
// prettier-ignore
params: {
projectDirPath: string;
themeType: ThemeType;
themeDirPath: string;
keycloakVersion: string;
usedResources: {
resourcesCommonFilePaths: string[];
} | undefined
}
) {
const { projectDirPath, themeType, themeDirPath, keycloakVersion } = params;
export type BuildOptionsLike = {
cacheDirPath: string;
npmWorkspaceRootDirPath: string;
};
// NOTE: Hack for 427
const usedResources = (() => {
const { usedResources } = params;
assert<BuildOptions extends BuildOptionsLike ? true : false>();
if (usedResources === undefined) {
return undefined;
}
assert(usedResources !== undefined);
return {
"resourcesCommonDirPaths": usedResources.resourcesCommonFilePaths.map(filePath => {
{
const splitArg = "/dist/";
if (filePath.includes(splitArg)) {
return filePath.split(splitArg)[0] + splitArg;
}
}
return pathDirname(filePath);
})
};
})();
export async function downloadKeycloakStaticResources(params: {
themeType: ThemeType;
themeDirPath: string;
keycloakVersion: string;
buildOptions: BuildOptionsLike;
}) {
const { themeType, themeDirPath, keycloakVersion, buildOptions } = params;
const tmpDirPath = pathJoin(
themeDirPath,
"..",
`tmp_suLeKsxId_${crypto.createHash("sha256").update(`${themeType}-${keycloakVersion}`).digest("hex").slice(0, 8)}`
);
await downloadBuiltinKeycloakTheme({
projectDirPath,
keycloakVersion,
"destDirPath": tmpDirPath
"destDirPath": tmpDirPath,
buildOptions
});
const resourcesPath = pathJoin(themeDirPath, themeType, "resources");
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", themeType, "resources"),
"destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesDirPathRelativeToPublicDir))
"destDirPath": resourcesPath
});
transformCodebase({
"srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"),
"destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesCommonDirPathRelativeToPublicDir)),
"transformSourceCode":
usedResources === undefined
? undefined
: ({ fileRelativePath, sourceCode }) => {
if (usedResources.resourcesCommonDirPaths.find(dirPath => fileRelativePath.startsWith(dirPath)) === undefined) {
return undefined;
}
return { "modifiedSourceCode": sourceCode };
}
"destDirPath": pathJoin(resourcesPath, resources_common)
});
fs.rmSync(tmpDirPath, { "recursive": true, "force": true });
rmSync(tmpDirPath, { "recursive": true, "force": true });
}

View File

@ -1,4 +1,4 @@
import type { ThemeType } from "../generateFtl";
import type { ThemeType } from "../../constants";
import { crawl } from "../../tools/crawl";
import { join as pathJoin } from "path";
import { readFileSync } from "fs";

View File

@ -1,77 +1,115 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin } from "path";
import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode";
import { join as pathJoin, basename as pathBasename, resolve as pathResolve, dirname as pathDirname } from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds, themeTypes, type ThemeType } from "../generateFtl";
import { basenameOfKeycloakDirInPublicDir } from "../../mockTestingResourcesPath";
import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds } from "../generateFtl";
import {
type ThemeType,
lastKeycloakVersionWithAccountV1,
keycloak_resources,
retrocompatPostfix,
accountV1ThemeName,
basenameOfTheKeycloakifyResourcesDir
} from "../../constants";
import { isInside } from "../../tools/isInside";
import type { BuildOptions } from "../BuildOptions";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../buildOptions";
import { assert, type Equals } from "tsafe/assert";
import { downloadKeycloakStaticResources } from "./downloadKeycloakStaticResources";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import { generateMessageProperties } from "./generateMessageProperties";
import { readStaticResourcesUsage } from "./readStaticResourcesUsage";
import { bringInAccountV1 } from "./bringInAccountV1";
import { rmSync } from "../../tools/fs.rmSync";
export type BuildOptionsLike = {
themeName: string;
bundler: "vite" | "webpack";
extraThemeProperties: string[] | undefined;
themeVersion: string;
keycloakVersionDefaultAssets: string;
loginThemeResourcesFromKeycloakVersion: string;
keycloakifyBuildDirPath: string;
reactAppBuildDirPath: string;
cacheDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
doBuildRetrocompatAccountTheme: boolean;
themeNames: string[];
npmWorkspaceRootDirPath: string;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function generateTheme(params: {
projectDirPath: string;
reactAppBuildDirPath: string;
keycloakThemeBuildingDirPath: string;
themeName: string;
themeSrcDirPath: string;
keycloakifySrcDirPath: string;
buildOptions: BuildOptionsLike;
keycloakifyVersion: string;
}): Promise<void> {
const {
projectDirPath,
reactAppBuildDirPath,
keycloakThemeBuildingDirPath,
themeSrcDirPath,
keycloakifySrcDirPath,
buildOptions,
keycloakifyVersion
} = params;
const { themeName, themeSrcDirPath, keycloakifySrcDirPath, buildOptions, keycloakifyVersion } = params;
const getThemeDirPath = (themeType: ThemeType | "email") =>
pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType);
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email"; isRetrocompat?: true }) => {
const { themeType, isRetrocompat = false } = params;
return pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme",
`${themeName}${isRetrocompat ? retrocompatPostfix : ""}`,
themeType
);
};
let allCssGlobalsToDefine: Record<string, string> = {};
const cssGlobalsToDefine: Record<string, string> = {};
let generateFtlFilesCode_glob: ReturnType<typeof generateFtlFilesCodeFactory>["generateFtlFilesCode"] | undefined = undefined;
const implementedThemeTypes: Record<ThemeType | "email", boolean> = {
"login": false,
"account": false,
"email": false
};
for (const themeType of themeTypes) {
for (const themeType of ["login", "account"] as const) {
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
continue;
}
const themeDirPath = getThemeDirPath(themeType);
implementedThemeTypes[themeType] = true;
copy_app_resources_to_theme_path: {
const isFirstPass = themeType.indexOf(themeType) === 0;
const themeTypeDirPath = getThemeTypeDirPath({ themeType });
if (!isFirstPass) {
break copy_app_resources_to_theme_path;
apply_replacers_and_move_to_theme_resources: {
const destDirPath = pathJoin(themeTypeDirPath, "resources", basenameOfTheKeycloakifyResourcesDir);
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
rmSync(destDirPath, { "recursive": true, "force": true });
if (themeType === "account" && implementedThemeTypes.login) {
// NOTE: We prevend doing it twice, it has been done for the login theme.
transformCodebase({
"srcDirPath": pathJoin(
getThemeTypeDirPath({
"themeType": "login"
}),
"resources",
basenameOfTheKeycloakifyResourcesDir
),
destDirPath
});
break apply_replacers_and_move_to_theme_resources;
}
transformCodebase({
"destDirPath": pathJoin(themeDirPath, "resources", "build"),
"srcDirPath": reactAppBuildDirPath,
"srcDirPath": buildOptions.reactAppBuildDirPath,
destDirPath,
"transformSourceCode": ({ filePath, sourceCode }) => {
//NOTE: Prevent cycles, excludes the folder we generated for debug in public/
// This should not happen if users follow the new instruction setup but we keep it for retrocompatibility.
if (
isInside({
"dirPath": pathJoin(reactAppBuildDirPath, basenameOfKeycloakDirInPublicDir),
"dirPath": pathJoin(buildOptions.reactAppBuildDirPath, keycloak_resources),
filePath
})
) {
@ -79,27 +117,21 @@ export async function generateTheme(params: {
}
if (/\.css?$/i.test(filePath)) {
const { cssGlobalsToDefine, fixedCssCode } = replaceImportsInCssCode({
const { cssGlobalsToDefine: cssGlobalsToDefineForThisFile, fixedCssCode } = replaceImportsInCssCode({
"cssCode": sourceCode.toString("utf8")
});
register_css_variables: {
if (!isFirstPass) {
break register_css_variables;
}
allCssGlobalsToDefine = {
...allCssGlobalsToDefine,
...cssGlobalsToDefine
};
}
Object.entries(cssGlobalsToDefineForThisFile).forEach(([key, value]) => {
cssGlobalsToDefine[key] = value;
});
return { "modifiedSourceCode": Buffer.from(fixedCssCode, "utf8") };
}
if (/\.js?$/i.test(filePath)) {
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": sourceCode.toString("utf8")
const { fixedJsCode } = replaceImportsInJsCode({
"jsCode": sourceCode.toString("utf8"),
buildOptions
});
return { "modifiedSourceCode": Buffer.from(fixedJsCode, "utf8") };
@ -110,21 +142,19 @@ export async function generateTheme(params: {
});
}
const generateFtlFilesCode =
generateFtlFilesCode_glob !== undefined
? generateFtlFilesCode_glob
: generateFtlFilesCodeFactory({
"indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"),
"cssGlobalsToDefine": allCssGlobalsToDefine,
buildOptions,
keycloakifyVersion,
themeType,
"fieldNames": readFieldNameUsage({
keycloakifySrcDirPath,
themeSrcDirPath,
themeType
})
}).generateFtlFilesCode;
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
"indexHtmlCode": fs.readFileSync(pathJoin(buildOptions.reactAppBuildDirPath, "index.html")).toString("utf8"),
cssGlobalsToDefine,
buildOptions,
keycloakifyVersion,
themeType,
"fieldNames": readFieldNameUsage({
keycloakifySrcDirPath,
themeSrcDirPath,
themeType
})
});
[
...(() => {
@ -142,75 +172,75 @@ export async function generateTheme(params: {
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.mkdirSync(themeDirPath, { "recursive": true });
fs.mkdirSync(themeTypeDirPath, { "recursive": true });
fs.writeFileSync(pathJoin(themeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
fs.writeFileSync(pathJoin(themeTypeDirPath, pageId), Buffer.from(ftlCode, "utf8"));
});
generateMessageProperties({
themeSrcDirPath,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeDirPath, "messages");
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
fs.mkdirSync(pathJoin(themeDirPath, "messages"), { "recursive": true });
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), { "recursive": true });
const propertiesFilePath = pathJoin(messagesDirPath, `messages_${languageTag}.properties`);
fs.writeFileSync(propertiesFilePath, Buffer.from(propertiesFileSource, "utf8"));
});
//TODO: Remove this block we left it for now only for backward compatibility
// we now have a separate script for this
copy_keycloak_resources_to_public: {
const keycloakDirInPublicDir = pathJoin(reactAppBuildDirPath, "..", "public", basenameOfKeycloakDirInPublicDir);
if (fs.existsSync(keycloakDirInPublicDir)) {
break copy_keycloak_resources_to_public;
}
await downloadKeycloakStaticResources({
projectDirPath,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
"themeDirPath": keycloakDirInPublicDir,
themeType,
"usedResources": undefined
});
if (themeType !== themeTypes[0]) {
break copy_keycloak_resources_to_public;
}
fs.writeFileSync(
pathJoin(keycloakDirInPublicDir, "README.txt"),
Buffer.from(
// prettier-ignore
[
"This is just a test folder that helps develop",
"the login and register page without having to run a Keycloak container"
].join(" ")
)
);
fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8"));
}
await downloadKeycloakStaticResources({
projectDirPath,
"keycloakVersion": buildOptions.keycloakVersionDefaultAssets,
themeDirPath,
"keycloakVersion": (() => {
switch (themeType) {
case "account":
return lastKeycloakVersionWithAccountV1;
case "login":
return buildOptions.loginThemeResourcesFromKeycloakVersion;
}
})(),
"themeDirPath": pathResolve(pathJoin(themeTypeDirPath, "..")),
themeType,
"usedResources": readStaticResourcesUsage({
keycloakifySrcDirPath,
themeSrcDirPath,
themeType
})
buildOptions
});
fs.writeFileSync(
pathJoin(themeDirPath, "theme.properties"),
Buffer.from([`parent=keycloak`, ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8")
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
return accountV1ThemeName;
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(buildOptions.extraThemeProperties ?? [])
].join("\n\n"),
"utf8"
)
);
if (themeType === "account" && buildOptions.doBuildRetrocompatAccountTheme) {
transformCodebase({
"srcDirPath": themeTypeDirPath,
"destDirPath": getThemeTypeDirPath({ themeType, "isRetrocompat": true }),
"transformSourceCode": ({ filePath, sourceCode }) => {
if (pathBasename(filePath) === "theme.properties") {
return {
"modifiedSourceCode": Buffer.from(
sourceCode.toString("utf8").replace(`parent=${accountV1ThemeName}`, "parent=keycloak"),
"utf8"
)
};
}
return { "modifiedSourceCode": sourceCode };
}
});
}
}
email: {
@ -220,9 +250,82 @@ export async function generateTheme(params: {
break email;
}
implementedThemeTypes.email = true;
transformCodebase({
"srcDirPath": emailThemeSrcDirPath,
"destDirPath": getThemeDirPath("email")
"destDirPath": getThemeTypeDirPath({ "themeType": "email" })
});
}
const parsedKeycloakThemeJson: { themes: { name: string; types: string[] }[] } = { "themes": [] };
buildOptions.themeNames.forEach(themeName =>
parsedKeycloakThemeJson.themes.push({
"name": themeName,
"types": Object.entries(implementedThemeTypes)
.filter(([, isImplemented]) => isImplemented)
.map(([themeType]) => themeType)
})
);
account_specific_extra_work: {
if (!implementedThemeTypes.account) {
break account_specific_extra_work;
}
await bringInAccountV1({ buildOptions });
parsedKeycloakThemeJson.themes.push({
"name": accountV1ThemeName,
"types": ["account"]
});
add_retrocompat_account_theme: {
if (!buildOptions.doBuildRetrocompatAccountTheme) {
break add_retrocompat_account_theme;
}
transformCodebase({
"srcDirPath": getThemeTypeDirPath({ "themeType": "account" }),
"destDirPath": getThemeTypeDirPath({ "themeType": "account", "isRetrocompat": true }),
"transformSourceCode": ({ filePath, sourceCode }) => {
if (pathBasename(filePath) === "theme.properties") {
return {
"modifiedSourceCode": Buffer.from(
sourceCode.toString("utf8").replace(`parent=${accountV1ThemeName}`, "parent=keycloak"),
"utf8"
)
};
}
return { "modifiedSourceCode": sourceCode };
}
});
buildOptions.themeNames.forEach(themeName =>
parsedKeycloakThemeJson.themes.push({
"name": `${themeName}${retrocompatPostfix}`,
"types": ["account"]
})
);
}
}
{
const keycloakThemeJsonFilePath = pathJoin(
buildOptions.keycloakifyBuildDirPath,
"src",
"main",
"resources",
"META-INF",
"keycloak-themes.json"
);
try {
fs.mkdirSync(pathDirname(keycloakThemeJsonFilePath));
} catch {}
fs.writeFileSync(keycloakThemeJsonFilePath, Buffer.from(JSON.stringify(parsedKeycloakThemeJson, null, 2), "utf8"));
}
}

View File

@ -1,9 +1,10 @@
import { crawl } from "../../tools/crawl";
import { type ThemeType, accountThemePageIds, loginThemePageIds } from "../generateFtl";
import { accountThemePageIds, loginThemePageIds } from "../generateFtl";
import { id } from "tsafe/id";
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
import * as fs from "fs";
import { join as pathJoin } from "path";
import type { ThemeType } from "../../constants";
export function readExtraPagesNames(params: { themeSrcDirPath: string; themeType: ThemeType }): string[] {
const { themeSrcDirPath, themeType } = params;

View File

@ -2,7 +2,7 @@ import { crawl } from "../../tools/crawl";
import { removeDuplicates } from "evt/tools/reducers/removeDuplicates";
import { join as pathJoin } from "path";
import * as fs from "fs";
import type { ThemeType } from "../generateFtl";
import type { ThemeType } from "../../constants";
/** Assumes the theme type exists */
export function readFieldNameUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): string[] {

View File

@ -1,76 +0,0 @@
import { crawl } from "../../tools/crawl";
import { join as pathJoin, sep as pathSep } from "path";
import * as fs from "fs";
import type { ThemeType } from "../generateFtl";
/** Assumes the theme type exists */
export function readStaticResourcesUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): {
resourcesCommonFilePaths: string[];
} {
const { keycloakifySrcDirPath, themeSrcDirPath, themeType } = params;
const resourcesCommonFilePaths = new Set<string>();
for (const srcDirPath of [pathJoin(keycloakifySrcDirPath, themeType), pathJoin(themeSrcDirPath, themeType)]) {
const filePaths = crawl({ "dirPath": srcDirPath, "returnedPathsType": "absolute" }).filter(filePath => /\.(ts|tsx|js|jsx)$/.test(filePath));
for (const filePath of filePaths) {
const rawSourceFile = fs.readFileSync(filePath).toString("utf8");
if (!rawSourceFile.includes("resourcesCommonPath") && !rawSourceFile.includes("resourcesPath")) {
continue;
}
const wrap = readPaths({ rawSourceFile });
wrap.resourcesCommonFilePaths.forEach(filePath => resourcesCommonFilePaths.add(filePath));
}
}
return {
"resourcesCommonFilePaths": Array.from(resourcesCommonFilePaths)
};
}
/** Exported for testing purpose */
export function readPaths(params: { rawSourceFile: string }): {
resourcesCommonFilePaths: string[];
} {
const { rawSourceFile } = params;
const resourcesCommonFilePaths = new Set<string>();
{
const regexp = new RegExp(`resourcesCommonPath\\s*}([^\`]+)\``, "g");
const matches = [...rawSourceFile.matchAll(regexp)];
for (const match of matches) {
const filePath = match[1];
resourcesCommonFilePaths.add(filePath);
}
}
{
const regexp = new RegExp(`resourcesCommonPath\\s*[+,]\\s*["']([^"'\`]+)["'\`]`, "g");
const matches = [...rawSourceFile.matchAll(regexp)];
for (const match of matches) {
const filePath = match[1];
resourcesCommonFilePaths.add(filePath);
}
}
const normalizePath = (filePath: string) => {
filePath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
filePath = filePath.replace(/\//g, pathSep);
return filePath;
};
return {
"resourcesCommonFilePaths": Array.from(resourcesCommonFilePaths).map(normalizePath)
};
}

View File

@ -1,163 +1,119 @@
import { generateTheme } from "./generateTheme";
import { generateJavaStackFiles } from "./generateJavaStackFiles";
import { join as pathJoin, relative as pathRelative, basename as pathBasename, sep as pathSep } from "path";
import { generatePom } from "./generatePom";
import { join as pathJoin, relative as pathRelative, basename as pathBasename, dirname as pathDirname, sep as pathSep } from "path";
import * as child_process from "child_process";
import { generateStartKeycloakTestingContainer } from "./generateStartKeycloakTestingContainer";
import * as fs from "fs";
import { readBuildOptions } from "./BuildOptions";
import { readBuildOptions } from "./buildOptions";
import { getLogger } from "../tools/logger";
import jar from "../tools/jar";
import { assert } from "tsafe/assert";
import { Equals } from "tsafe";
import { getThemeSrcDirPath } from "../getSrcDirPath";
import { getProjectRoot } from "../tools/getProjectRoot";
import { objectKeys } from "tsafe/objectKeys";
import { getThemeSrcDirPath } from "../getThemeSrcDirPath";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
import { readThisNpmProjectVersion } from "../tools/readThisNpmProjectVersion";
import { keycloakifyBuildOptionsForPostPostBuildScriptEnvName } from "../constants";
export async function main() {
const projectDirPath = process.cwd();
const buildOptions = readBuildOptions({
projectDirPath,
"processArgv": process.argv.slice(2)
});
const logger = getLogger({ "isSilent": buildOptions.isSilent });
logger.log("🔏 Building the keycloak theme...⌚");
const keycloakifyDirPath = getProjectRoot();
const { themeSrcDirPath } = getThemeSrcDirPath({ "reactAppRootDirPath": buildOptions.reactAppRootDirPath });
const { themeSrcDirPath } = getThemeSrcDirPath({ projectDirPath });
for (const themeName of [buildOptions.themeName, ...buildOptions.extraThemeNames]) {
for (const themeName of buildOptions.themeNames) {
await generateTheme({
projectDirPath,
"keycloakThemeBuildingDirPath": buildOptions.keycloakifyBuildDirPath,
themeName,
themeSrcDirPath,
"keycloakifySrcDirPath": pathJoin(keycloakifyDirPath, "src"),
"reactAppBuildDirPath": buildOptions.reactAppBuildDirPath,
"buildOptions": {
...buildOptions,
"themeName": themeName
},
"keycloakifyVersion": (() => {
const version = JSON.parse(fs.readFileSync(pathJoin(keycloakifyDirPath, "package.json")).toString("utf8"))["version"];
assert(typeof version === "string");
return version;
})()
"keycloakifySrcDirPath": pathJoin(getThisCodebaseRootDirPath(), "src"),
"keycloakifyVersion": readThisNpmProjectVersion(),
buildOptions
});
}
const { jarFilePath } = generateJavaStackFiles({
"keycloakThemeBuildingDirPath": buildOptions.keycloakifyBuildDirPath,
"implementedThemeTypes": (() => {
const implementedThemeTypes = {
"login": false,
"account": false,
"email": false
};
{
const { pomFileCode } = generatePom({ buildOptions });
for (const themeType of objectKeys(implementedThemeTypes)) {
if (!fs.existsSync(pathJoin(themeSrcDirPath, themeType))) {
continue;
}
implementedThemeTypes[themeType] = true;
}
return implementedThemeTypes;
})(),
buildOptions
});
switch (buildOptions.bundler) {
case "none":
logger.log("😱 Skipping bundling step, there will be no jar");
break;
case "keycloakify":
logger.log("🫶 Let keycloakify do its thang");
await jar({
"rootPath": buildOptions.keycloakifyBuildDirPath,
"version": buildOptions.themeVersion,
"groupId": buildOptions.groupId,
"artifactId": buildOptions.artifactId,
"targetPath": jarFilePath
});
break;
case "mvn":
logger.log("🫙 Run maven to deliver a jar");
child_process.execSync("mvn package", { "cwd": buildOptions.keycloakifyBuildDirPath });
break;
default:
assert<Equals<typeof buildOptions.bundler, never>>(false);
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8"));
}
// We want, however, to test in a container running the latest Keycloak version
const containerKeycloakVersion = "21.1.2";
const containerKeycloakVersion = "23.0.6";
const jarFilePath = pathJoin(buildOptions.keycloakifyBuildDirPath, "target", `${buildOptions.artifactId}-${buildOptions.themeVersion}.jar`);
generateStartKeycloakTestingContainer({
keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath,
"keycloakVersion": containerKeycloakVersion,
jarFilePath,
buildOptions
});
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, ".gitignore"), Buffer.from("*", "utf8"));
child_process.execSync("npx vite", {
"cwd": buildOptions.reactAppRootDirPath,
"env": {
...process.env,
[keycloakifyBuildOptionsForPostPostBuildScriptEnvName]: JSON.stringify(buildOptions)
}
});
create_jar: {
if (!buildOptions.doCreateJar) {
break create_jar;
}
child_process.execSync("mvn clean install", { "cwd": buildOptions.keycloakifyBuildDirPath });
const jarDirPath = pathDirname(jarFilePath);
const retrocompatJarFilePath = pathJoin(jarDirPath, "retrocompat-" + pathBasename(jarFilePath));
fs.renameSync(pathJoin(jarDirPath, "original-" + pathBasename(jarFilePath)), retrocompatJarFilePath);
fs.writeFileSync(
pathJoin(jarDirPath, "README.md"),
Buffer.from(
[
`- The ${jarFilePath} is to be used in Keycloak 23 and up. `,
`- The ${retrocompatJarFilePath} is to be used in Keycloak 22 and below.`,
` Note that Keycloak 22 is only supported for login and email theme but not for account themes. `
].join("\n"),
"utf8"
)
);
}
logger.log(
[
"",
`✅ Your keycloak theme has been generated and bundled into .${pathSep}${pathRelative(projectDirPath, 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.
//"Using Helm (https://github.com/codecentric/helm-charts), edit to reflect:",
"",
"value.yaml: ",
" extraInitContainers: |",
" - name: realm-ext-provider",
" image: curlimages/curl",
" imagePullPolicy: IfNotPresent",
" command:",
" - sh",
" args:",
" - -c",
` - curl -L -f -S -o /extensions/${pathBasename(jarFilePath)} https://AN.URL.FOR/${pathBasename(jarFilePath)}`,
" volumeMounts:",
" - name: extensions",
" mountPath: /extensions",
" ",
" extraVolumeMounts: |",
" - name: extensions",
" mountPath: /opt/keycloak/providers",
" extraEnv: |",
" - name: KEYCLOAK_USER",
" value: admin",
" - name: KEYCLOAK_PASSWORD",
" value: xxxxxxxxx",
" - name: JAVA_OPTS",
" value: -Dkeycloak.profile=preview",
"",
...(!buildOptions.doCreateJar
? []
: [
`✅ Your keycloak theme has been generated and bundled into .${pathSep}${pathRelative(
buildOptions.reactAppRootDirPath,
jarFilePath
)} 🚀`
]),
"",
`To test your theme locally you can spin up a Keycloak ${containerKeycloakVersion} container image with the theme pre loaded by running:`,
"",
`👉 $ .${pathSep}${pathRelative(
projectDirPath,
buildOptions.reactAppRootDirPath,
pathJoin(buildOptions.keycloakifyBuildDirPath, generateStartKeycloakTestingContainer.basename)
)} 👈`,
"",
`Test with different Keycloak versions by editing the .sh file. see available versions here: https://quay.io/repository/keycloak/keycloak?tab=tags`,
``,
`Once your container is up and running: `,
"- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈",
`- Create a realm: Master -> AddRealm -> Name: myrealm`,
`- Enable registration: Realm settings -> Login tab -> User registration: on`,
`- Enable the Account theme (optional): Realm settings -> Themes tab -> Account theme: ${buildOptions.themeName}`,
` Clients -> account -> Login theme: ${buildOptions.themeName}`,
`- Enable the email theme (optional): Realm settings -> Themes tab -> Email theme: ${buildOptions.themeName} (option will appear only if you have ran npx initialize-email-theme)`,
`- Enable the Account theme (optional): Realm settings -> Themes tab -> Account theme: ${buildOptions.themeNames[0]}`,
` Clients -> account -> Login theme: ${buildOptions.themeNames[0]}`,
`- Enable the email theme (optional): Realm settings -> Themes tab -> Email theme: ${buildOptions.themeNames[0]} (option will appear only if you have ran npx initialize-email-theme)`,
`- Create a client Clients -> Create -> Client ID: myclient`,
` Root URL: https://www.keycloak.org/app/`,
` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`,
` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`,
` Web origins: *`,
` Login Theme: ${buildOptions.themeName}`,
` Login Theme: ${buildOptions.themeNames[0]}`,
` Save (button at the bottom of the page)`,
``,
`- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`,

View File

@ -1,57 +0,0 @@
import * as fs from "fs";
import { assert } from "tsafe";
import type { Equals } from "tsafe";
import { z } from "zod";
import { pathJoin } from "../tools/pathJoin";
export const bundlers = ["mvn", "keycloakify", "none"] as const;
export type Bundler = (typeof bundlers)[number];
export type ParsedPackageJson = {
name: string;
version?: string;
homepage?: string;
keycloakify?: {
extraThemeProperties?: string[];
areAppAndKeycloakServerSharingSameDomain?: boolean;
artifactId?: string;
groupId?: string;
bundler?: Bundler;
keycloakVersionDefaultAssets?: string;
reactAppBuildDirPath?: string;
keycloakifyBuildDirPath?: string;
themeName?: string;
extraThemeNames?: string[];
};
};
export const zParsedPackageJson = z.object({
"name": z.string(),
"version": z.string().optional(),
"homepage": z.string().optional(),
"keycloakify": z
.object({
"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(),
"reactAppBuildDirPath": z.string().optional(),
"keycloakifyBuildDirPath": z.string().optional(),
"themeName": z.string().optional(),
"extraThemeNames": z.array(z.string()).optional()
})
.optional()
});
assert<Equals<ReturnType<(typeof zParsedPackageJson)["parse"]>, ParsedPackageJson>>();
let parsedPackageJson: undefined | ReturnType<(typeof zParsedPackageJson)["parse"]>;
export function getParsedPackageJson(params: { projectDirPath: string }) {
const { projectDirPath } = params;
if (parsedPackageJson) {
return parsedPackageJson;
}
parsedPackageJson = zParsedPackageJson.parse(JSON.parse(fs.readFileSync(pathJoin(projectDirPath, "package.json")).toString("utf8")));
return parsedPackageJson;
}

View File

@ -1,51 +0,0 @@
import { ftlValuesGlobalName } from "../ftlValuesGlobalName";
export function replaceImportsFromStaticInJsCode(params: { jsCode: string }): { fixedJsCode: string } {
/*
NOTE:
When we have urlOrigin defined it means that
we are building with --external-assets
so we have to make sur that the fixed js code will run
inside and outside keycloak.
When urlOrigin isn't defined we can assume the fixedJsCode
will always run in keycloak context.
*/
const { jsCode } = params;
const getReplaceArgs = (language: "js" | "css"): Parameters<typeof String.prototype.replace> => [
new RegExp(`([a-zA-Z_]+)\\.([a-zA-Z]+)=(function\\(([a-z]+)\\){return|([a-z]+)=>)"static\\/${language}\\/"`, "g"),
(...[, n, u, matchedFunction, eForFunction]) => {
const isArrowFunction = matchedFunction.includes("=>");
const e = isArrowFunction ? matchedFunction.replace("=>", "").trim() : eForFunction;
return `
${n}[(function(){
var pd = Object.getOwnPropertyDescriptor(${n}, "p");
if( pd === undefined || pd.configurable ){
Object.defineProperty(${n}, "p", {
get: function() { return window.${ftlValuesGlobalName}.url.resourcesPath; },
set: function() {}
});
}
return "${u}";
})()] = ${isArrowFunction ? `${e} =>` : `function(${e}) { return `} "/build/static/${language}/"`
.replace(/\s+/g, " ")
.trim();
}
];
const fixedJsCode = jsCode
.replace(...getReplaceArgs("js"))
.replace(...getReplaceArgs("css"))
.replace(/[a-zA-Z]+\.[a-zA-Z]+\+"static\//g, `window.${ftlValuesGlobalName}.url.resourcesPath + "/build/static/`)
//TODO: Write a test case for this
.replace(
/".chunk.css",([a-zA-Z])+=[a-zA-Z]+\.[a-zA-Z]+\+([a-zA-Z]+),/,
(...[, group1, group2]) => `".chunk.css",${group1} = window.${ftlValuesGlobalName}.url.resourcesPath + "/build/" + ${group2},`
);
return { fixedJsCode };
}

View File

@ -1,6 +1,7 @@
import * as crypto from "crypto";
import type { BuildOptions } from "../BuildOptions";
import type { BuildOptions } from "../buildOptions";
import { assert } from "tsafe/assert";
import { basenameOfTheKeycloakifyResourcesDir } from "../../constants";
export type BuildOptionsLike = {
urlPathname: string | undefined;
@ -16,7 +17,7 @@ export function replaceImportsInCssCode(params: { cssCode: string }): {
const cssGlobalsToDefine: Record<string, string> = {};
new Set(cssCode.match(/url\(["']?\/[^/][^)"']+["']?\)[^;}]*/g) ?? []).forEach(
new Set(cssCode.match(/url\(["']?\/[^/][^)"']+["']?\)[^;}]*?/g) ?? []).forEach(
match => (cssGlobalsToDefine["url" + crypto.createHash("sha256").update(match).digest("hex").substring(0, 15)] = match)
);
@ -45,7 +46,7 @@ export function generateCssCodeToDefineGlobals(params: { cssGlobalsToDefine: Rec
`--${cssVariableName}:`,
cssGlobalsToDefine[cssVariableName].replace(
new RegExp(`url\\(${(buildOptions.urlPathname ?? "/").replace(/\//g, "\\/")}`, "g"),
"url(${url.resourcesPath}/build/"
`url(\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/`
)
].join(" ")
)

View File

@ -1,5 +1,6 @@
import type { BuildOptions } from "../BuildOptions";
import type { BuildOptions } from "../buildOptions";
import { assert } from "tsafe/assert";
import { basenameOfTheKeycloakifyResourcesDir } from "../../constants";
export type BuildOptionsLike = {
urlPathname: string | undefined;
@ -16,7 +17,7 @@ export function replaceImportsInInlineCssCode(params: { cssCode: string; buildOp
buildOptions.urlPathname === undefined
? /url\(["']?\/([^/][^)"']+)["']?\)/g
: new RegExp(`url\\(["']?${buildOptions.urlPathname}([^)"']+)["']?\\)`, "g"),
(...[, group]) => `url(\${url.resourcesPath}/build/${group})`
(...[, group]) => `url(\${url.resourcesPath}/${basenameOfTheKeycloakifyResourcesDir}/${group})`
);
return { fixedCssCode };

View File

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

View File

@ -0,0 +1,66 @@
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../buildOptions";
import { replaceImportsInJsCode_vite } from "./vite";
import { replaceImportsInJsCode_webpack } from "./webpack";
import * as fs from "fs";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
bundler: "vite" | "webpack";
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function replaceImportsInJsCode(params: { jsCode: string; buildOptions: BuildOptionsLike }) {
const { jsCode, buildOptions } = params;
const { fixedJsCode } = (() => {
switch (buildOptions.bundler) {
case "vite":
return replaceImportsInJsCode_vite({
jsCode,
buildOptions,
"basenameOfAssetsFiles": readAssetsDirSync({
"assetsDirPath": params.buildOptions.assetsDirPath
})
});
case "webpack":
return replaceImportsInJsCode_webpack({
jsCode,
buildOptions
});
}
})();
return { fixedJsCode };
}
const { readAssetsDirSync } = (() => {
let cache:
| {
assetsDirPath: string;
basenameOfAssetsFiles: string[];
}
| undefined = undefined;
function readAssetsDirSync(params: { assetsDirPath: string }): string[] {
const { assetsDirPath } = params;
if (cache !== undefined && cache.assetsDirPath === assetsDirPath) {
return cache.basenameOfAssetsFiles;
}
const basenameOfAssetsFiles = fs.readdirSync(assetsDirPath);
cache = {
assetsDirPath,
basenameOfAssetsFiles
};
return basenameOfAssetsFiles;
}
return { readAssetsDirSync };
})();

View File

@ -0,0 +1,85 @@
import { nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir } from "../../../constants";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../buildOptions";
import * as nodePath from "path";
import { replaceAll } from "../../../tools/String.prototype.replaceAll";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function replaceImportsInJsCode_vite(params: {
jsCode: string;
buildOptions: BuildOptionsLike;
basenameOfAssetsFiles: string[];
systemType?: "posix" | "win32";
}): {
fixedJsCode: string;
} {
const { jsCode, buildOptions, basenameOfAssetsFiles, systemType = nodePath.sep === "/" ? "posix" : "win32" } = params;
const { relative: pathRelative, sep: pathSep } = nodePath[systemType];
let fixedJsCode = jsCode;
replace_base_javacript_import: {
if (buildOptions.urlPathname === undefined) {
break replace_base_javacript_import;
}
// Optimization
if (!jsCode.includes(buildOptions.urlPathname)) {
break replace_base_javacript_import;
}
// Replace `Hv=function(e){return"/abcde12345/"+e}` by `Hv=function(e){return"/"+e}`
fixedJsCode = fixedJsCode.replace(
new RegExp(
`([\\w\\$][\\w\\d\\$]*)=function\\(([\\w\\$][\\w\\d\\$]*)\\)\\{return"${replaceAll(buildOptions.urlPathname, "/", "\\/")}"\\+\\2\\}`,
"g"
),
(...[, funcName, paramName]) => `${funcName}=function(${paramName}){return"/"+${paramName}}`
);
}
replace_javascript_relatives_import_paths: {
// Example: "assets/ or "foo/bar/"
const staticDir = (() => {
let out = pathRelative(buildOptions.reactAppBuildDirPath, buildOptions.assetsDirPath);
out = replaceAll(out, pathSep, "/") + "/";
if (out === "/") {
throw new Error(`The assetsDirPath must be a subdirectory of reactAppBuildDirPath`);
}
return out;
})();
// Optimization
if (!jsCode.includes(staticDir)) {
break replace_javascript_relatives_import_paths;
}
basenameOfAssetsFiles
.map(basenameOfAssetsFile => `${staticDir}${basenameOfAssetsFile}`)
.forEach(relativePathOfAssetFile => {
fixedJsCode = replaceAll(
fixedJsCode,
`"${relativePathOfAssetFile}"`,
`(window.${nameOfTheGlobal}.url.resourcesPath.substring(1) + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
);
fixedJsCode = replaceAll(
fixedJsCode,
`"${buildOptions.urlPathname ?? "/"}${relativePathOfAssetFile}"`,
`(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${relativePathOfAssetFile}")`
);
});
}
return { fixedJsCode };
}

View File

@ -0,0 +1,76 @@
import { nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir } from "../../../constants";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../../buildOptions";
import * as nodePath from "path";
import { replaceAll } from "../../../tools/String.prototype.replaceAll";
export type BuildOptionsLike = {
reactAppBuildDirPath: string;
assetsDirPath: string;
urlPathname: string | undefined;
};
assert<BuildOptions extends BuildOptionsLike ? true : false>();
export function replaceImportsInJsCode_webpack(params: { jsCode: string; buildOptions: BuildOptionsLike; systemType?: "posix" | "win32" }): {
fixedJsCode: string;
} {
const { jsCode, buildOptions, systemType = nodePath.sep === "/" ? "posix" : "win32" } = params;
const { relative: pathRelative, sep: pathSep } = nodePath[systemType];
let fixedJsCode = jsCode;
if (buildOptions.urlPathname !== undefined) {
// "__esModule",{value:!0})},n.p="/foo-bar/",function(){if("undefined" -> ... n.p="/" ...
fixedJsCode = fixedJsCode.replace(
new RegExp(`,([a-zA-Z]\\.[a-zA-Z])="${replaceAll(buildOptions.urlPathname, "/", "\\/")}",`, "g"),
(...[, assignTo]) => `,${assignTo}="/",`
);
}
// Example: "static/ or "foo/bar/"
const staticDir = (() => {
let out = pathRelative(buildOptions.reactAppBuildDirPath, buildOptions.assetsDirPath);
out = replaceAll(out, pathSep, "/") + "/";
if (out === "/") {
throw new Error(`The assetsDirPath must be a subdirectory of reactAppBuildDirPath`);
}
return out;
})();
const getReplaceArgs = (language: "js" | "css"): Parameters<typeof String.prototype.replace> => [
new RegExp(`([a-zA-Z_]+)\\.([a-zA-Z]+)=(function\\(([a-z]+)\\){return|([a-z]+)=>)"${staticDir.replace(/\//g, "\\/")}${language}\\/"`, "g"),
(...[, n, u, matchedFunction, eForFunction]) => {
const isArrowFunction = matchedFunction.includes("=>");
const e = isArrowFunction ? matchedFunction.replace("=>", "").trim() : eForFunction;
return `
${n}[(function(){
var pd = Object.getOwnPropertyDescriptor(${n}, "p");
if( pd === undefined || pd.configurable ){
Object.defineProperty(${n}, "p", {
get: function() { return window.${nameOfTheGlobal}.url.resourcesPath; },
set: function() {}
});
}
return "${u}";
})()] = ${isArrowFunction ? `${e} =>` : `function(${e}) { return `} "/${basenameOfTheKeycloakifyResourcesDir}/${staticDir}${language}/"`
.replace(/\s+/g, " ")
.trim();
}
];
fixedJsCode = fixedJsCode
.replace(...getReplaceArgs("js"))
.replace(...getReplaceArgs("css"))
.replace(
new RegExp(`[a-zA-Z]+\\.[a-zA-Z]+\\+"${staticDir.replace(/\//g, "\\/")}`, "g"),
`window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/${staticDir}`
);
return { fixedJsCode };
}

View File

@ -1,5 +0,0 @@
import { pathJoin } from "./tools/pathJoin";
export const basenameOfKeycloakDirInPublicDir = "keycloak-resources";
export const resourcesDirPathRelativeToPublicDir = pathJoin(basenameOfKeycloakDirInPublicDir, "resources");
export const resourcesCommonDirPathRelativeToPublicDir = pathJoin(resourcesDirPathRelativeToPublicDir, "resources_common");

View File

@ -1,6 +1,7 @@
import { getLatestsSemVersionedTagFactory } from "./tools/octokit-addons/getLatestsSemVersionedTag";
import { Octokit } from "@octokit/rest";
import cliSelect from "cli-select";
import { lastKeycloakVersionWithAccountV1 } from "./constants";
export async function promptKeycloakVersion() {
const { getLatestsSemVersionedTag } = (() => {
@ -22,10 +23,10 @@ export async function promptKeycloakVersion() {
const tags = [
...(await getLatestsSemVersionedTag({
"count": 10,
"doIgnoreBeta": true,
"owner": "keycloak",
"repo": "keycloak"
}).then(arr => arr.map(({ tag }) => tag))),
lastKeycloakVersionWithAccountV1,
"11.0.3"
];

View File

@ -1,73 +0,0 @@
export type NpmModuleVersion = {
major: number;
minor: number;
patch: number;
betaPreRelease?: number;
};
export namespace NpmModuleVersion {
export function parse(versionStr: string): NpmModuleVersion {
const match = versionStr.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-beta.([0-9]+))?/);
if (!match) {
throw new Error(`${versionStr} is not a valid NPM version`);
}
return {
"major": parseInt(match[1]),
"minor": parseInt(match[2]),
"patch": parseInt(match[3]),
...(() => {
const str = match[4];
return str === undefined ? {} : { "betaPreRelease": parseInt(str) };
})()
};
}
export function stringify(v: NpmModuleVersion) {
return `${v.major}.${v.minor}.${v.patch}${v.betaPreRelease === undefined ? "" : `-beta.${v.betaPreRelease}`}`;
}
/**
*
* v1 < v2 => -1
* v1 === v2 => 0
* v1 > v2 => 1
*
*/
export function compare(v1: NpmModuleVersion, v2: NpmModuleVersion): -1 | 0 | 1 {
const sign = (diff: number): -1 | 0 | 1 => (diff === 0 ? 0 : diff < 0 ? -1 : 1);
const noUndefined = (n: number | undefined) => n ?? Infinity;
for (const level of ["major", "minor", "patch", "betaPreRelease"] as const) {
if (noUndefined(v1[level]) !== noUndefined(v2[level])) {
return sign(noUndefined(v1[level]) - noUndefined(v2[level]));
}
}
return 0;
}
/*
console.log(compare(parse("3.0.0-beta.3"), parse("3.0.0")) === -1 )
console.log(compare(parse("3.0.0-beta.3"), parse("3.0.0-beta.4")) === -1 )
console.log(compare(parse("3.0.0-beta.3"), parse("4.0.0")) === -1 )
*/
export function bumpType(params: { versionBehindStr: string; versionAheadStr: string }): "major" | "minor" | "patch" | "betaPreRelease" | "same" {
const versionAhead = parse(params.versionAheadStr);
const versionBehind = parse(params.versionBehindStr);
if (compare(versionBehind, versionAhead) === 1) {
throw new Error(`Version regression ${versionBehind} -> ${versionAhead}`);
}
for (const level of ["major", "minor", "patch", "betaPreRelease"] as const) {
if (versionBehind[level] !== versionAhead[level]) {
return level;
}
}
return "same";
}
}

View File

@ -0,0 +1,12 @@
type PropertiesThatCanBeUndefined<T extends Record<string, unknown>> = {
[Key in keyof T]: undefined extends T[Key] ? Key : never;
}[keyof T];
/**
* OptionalIfCanBeUndefined<{ p1: string | undefined; p2: string; }>
* is
* { p1?: string | undefined; p2: string }
*/
export type OptionalIfCanBeUndefined<T extends Record<string, unknown>> = {
[K in PropertiesThatCanBeUndefined<T>]?: T[K];
} & { [K in Exclude<keyof T, PropertiesThatCanBeUndefined<T>>]: T[K] };

99
src/bin/tools/SemVer.ts Normal file
View File

@ -0,0 +1,99 @@
export type SemVer = {
major: number;
minor: number;
patch: number;
rc?: number;
parsedFrom: string;
};
export namespace SemVer {
const bumpTypes = ["major", "minor", "patch", "rc", "no bump"] as const;
export type BumpType = (typeof bumpTypes)[number];
export function parse(versionStr: string): SemVer {
const match = versionStr.match(/^v?([0-9]+)\.([0-9]+)(?:\.([0-9]+))?(?:-rc.([0-9]+))?$/);
if (!match) {
throw new Error(`${versionStr} is not a valid semantic version`);
}
const semVer: Omit<SemVer, "parsedFrom"> = {
"major": parseInt(match[1]),
"minor": parseInt(match[2]),
"patch": (() => {
const str = match[3];
return str === undefined ? 0 : parseInt(str);
})(),
...(() => {
const str = match[4];
return str === undefined ? {} : { "rc": parseInt(str) };
})()
};
const initialStr = stringify(semVer);
Object.defineProperty(semVer, "parsedFrom", {
"enumerable": true,
"get": function () {
const currentStr = stringify(this);
if (currentStr !== initialStr) {
throw new Error(`SemVer.parsedFrom can't be read anymore, the version have been modified from ${initialStr} to ${currentStr}`);
}
return versionStr;
}
});
return semVer as any;
}
export function stringify(v: Omit<SemVer, "parsedFrom">): string {
return `${v.major}.${v.minor}.${v.patch}${v.rc === undefined ? "" : `-rc.${v.rc}`}`;
}
/**
*
* v1 < v2 => -1
* v1 === v2 => 0
* v1 > v2 => 1
*
*/
export function compare(v1: SemVer, v2: SemVer): -1 | 0 | 1 {
const sign = (diff: number): -1 | 0 | 1 => (diff === 0 ? 0 : diff < 0 ? -1 : 1);
const noUndefined = (n: number | undefined) => n ?? Infinity;
for (const level of ["major", "minor", "patch", "rc"] as const) {
if (noUndefined(v1[level]) !== noUndefined(v2[level])) {
return sign(noUndefined(v1[level]) - noUndefined(v2[level]));
}
}
return 0;
}
/*
console.log(compare(parse("3.0.0-rc.3"), parse("3.0.0")) === -1 )
console.log(compare(parse("3.0.0-rc.3"), parse("3.0.0-rc.4")) === -1 )
console.log(compare(parse("3.0.0-rc.3"), parse("4.0.0")) === -1 )
*/
export function bumpType(params: { versionBehind: string | SemVer; versionAhead: string | SemVer }): BumpType | "no bump" {
const versionAhead = typeof params.versionAhead === "string" ? parse(params.versionAhead) : params.versionAhead;
const versionBehind = typeof params.versionBehind === "string" ? parse(params.versionBehind) : params.versionBehind;
if (compare(versionBehind, versionAhead) === 1) {
throw new Error(`Version regression ${stringify(versionBehind)} -> ${stringify(versionAhead)}`);
}
for (const level of ["major", "minor", "patch", "rc"] as const) {
if (versionBehind[level] !== versionAhead[level]) {
return level;
}
}
return "no bump";
}
}

View File

@ -0,0 +1,30 @@
export function replaceAll(string: string, searchValue: string | RegExp, replaceValue: string): string {
if ((string as any).replaceAll !== undefined) {
return (string as any).replaceAll(searchValue, replaceValue);
}
// If the searchValue is a string
if (typeof searchValue === "string") {
// Escape special characters in the string to be used in a regex
var escapedSearchValue = searchValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
var regex = new RegExp(escapedSearchValue, "g");
return string.replace(regex, replaceValue);
}
// If the searchValue is a global RegExp, use it directly
if (searchValue instanceof RegExp && searchValue.global) {
return string.replace(searchValue, replaceValue);
}
// If the searchValue is a non-global RegExp, throw an error
if (searchValue instanceof RegExp) {
throw new TypeError("replaceAll must be called with a global RegExp");
}
// Convert searchValue to string if it's not a string or RegExp
var searchString = String(searchValue);
var regexFromString = new RegExp(searchString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
return string.replace(regexFromString, replaceValue);
}

View File

@ -1,17 +1,17 @@
import * as fs from "fs";
import * as path from "path";
import { join as pathJoin, relative as pathRelative } from "path";
const crawlRec = (dir_path: string, paths: string[]) => {
for (const file_name of fs.readdirSync(dir_path)) {
const file_path = path.join(dir_path, file_name);
const crawlRec = (dirPath: string, filePaths: string[]) => {
for (const basename of fs.readdirSync(dirPath)) {
const fileOrDirPath = pathJoin(dirPath, basename);
if (fs.lstatSync(file_path).isDirectory()) {
crawlRec(file_path, paths);
if (fs.lstatSync(fileOrDirPath).isDirectory()) {
crawlRec(fileOrDirPath, filePaths);
continue;
}
paths.push(file_path);
filePaths.push(fileOrDirPath);
}
};
@ -27,6 +27,6 @@ export function crawl(params: { dirPath: string; returnedPathsType: "absolute" |
case "absolute":
return filePaths;
case "relative to dirPath":
return filePaths.map(filePath => path.relative(dirPath, filePath));
return filePaths.map(filePath => pathRelative(dirPath, filePath));
}
}

View File

@ -1,233 +0,0 @@
import { exec as execCallback } from "child_process";
import { createHash } from "crypto";
import { mkdir, readFile, stat, writeFile, unlink, rm } from "fs/promises";
import fetch, { type FetchOptions } from "make-fetch-happen";
import { dirname as pathDirname, join as pathJoin, resolve as pathResolve, sep as pathSep } from "path";
import { assert } from "tsafe/assert";
import { promisify } from "util";
import { transformCodebase } from "./transformCodebase";
import { unzip, zip } from "./unzip";
const exec = promisify(execCallback);
function generateFileNameFromURL(params: {
url: string;
preCacheTransform:
| {
actionCacheId: string;
actionFootprint: string;
}
| undefined;
}): string {
const { preCacheTransform } = params;
// Parse the URL
const url = new URL(params.url);
// Extract pathname and remove leading slashes
let fileName = url.pathname.replace(/^\//, "").replace(/\//g, "_");
// Optionally, add query parameters replacing special characters
if (url.search) {
fileName += url.search.replace(/[&=?]/g, "-");
}
// Replace any characters that are not valid in filenames
fileName = fileName.replace(/[^a-zA-Z0-9-_]/g, "");
// Trim or pad the fileName to a specific length
fileName = fileName.substring(0, 50);
add_pre_cache_transform: {
if (preCacheTransform === undefined) {
break add_pre_cache_transform;
}
// Sanitize actionCacheId the same way as other components
const sanitizedActionCacheId = preCacheTransform.actionCacheId.replace(/[^a-zA-Z0-9-_]/g, "_");
fileName += `_${sanitizedActionCacheId}_${createHash("sha256").update(preCacheTransform.actionFootprint).digest("hex").substring(0, 5)}`;
}
return fileName;
}
async function exists(path: string) {
try {
await stat(path);
return true;
} catch (error) {
if ((error as Error & { code: string }).code === "ENOENT") return false;
throw error;
}
}
function ensureArray<T>(arg0: T | T[]) {
return Array.isArray(arg0) ? arg0 : typeof arg0 === "undefined" ? [] : [arg0];
}
function ensureSingleOrNone<T>(arg0: T | T[]) {
if (!Array.isArray(arg0)) return arg0;
if (arg0.length === 0) return undefined;
if (arg0.length === 1) return arg0[0];
throw new Error("Illegal configuration, expected a single value but found multiple: " + arg0.map(String).join(", "));
}
type NPMConfig = Record<string, string | string[]>;
const npmConfigReducer = (cfg: NPMConfig, [key, value]: [string, string]) =>
key in cfg ? { ...cfg, [key]: [...ensureArray(cfg[key]), value] } : { ...cfg, [key]: value };
/**
* Get npm configuration as map
*/
async function getNmpConfig() {
return readNpmConfig().then(parseNpmConfig);
}
function readNpmConfig(): Promise<string> {
return (async function callee(depth: number): Promise<string> {
const cwd = pathResolve(pathJoin(...[process.cwd(), ...Array(depth).fill("..")]));
let stdout: string;
try {
stdout = await exec("npm config get", { "encoding": "utf8", cwd }).then(({ stdout }) => stdout);
} catch (error) {
if (String(error).includes("ENOWORKSPACES")) {
assert(cwd !== pathSep);
return callee(depth + 1);
}
throw error;
}
return stdout;
})(0);
}
function parseNpmConfig(stdout: string) {
return stdout
.split("\n")
.filter(line => !line.startsWith(";"))
.map(line => line.trim())
.map(line => line.split("=", 2) as [string, string])
.reduce(npmConfigReducer, {} as NPMConfig);
}
function maybeBoolean(arg0: string | undefined) {
return typeof arg0 === "undefined" ? undefined : Boolean(arg0);
}
function chunks<T>(arr: T[], size: number = 2) {
return arr.map((_, i) => i % size == 0 && arr.slice(i, i + size)).filter(Boolean) as T[][];
}
async function readCafile(cafile: string) {
const cafileContent = await readFile(cafile, "utf-8");
return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(ca => ca.join("").replace(/^\n/, "").replace(/\n/g, "\\n"));
}
/**
* Get proxy and ssl configuration from npm config files. Note that we don't care about
* proxy config in env vars, because make-fetch-happen will do that for us.
*
* @returns proxy configuration
*/
async function getFetchOptions(): Promise<Pick<FetchOptions, "proxy" | "noProxy" | "strictSSL" | "ca" | "cert">> {
const cfg = await getNmpConfig();
const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]);
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
const strictSSL = maybeBoolean(ensureSingleOrNone(cfg["strict-ssl"]));
const cert = cfg["cert"];
const ca = ensureArray(cfg["ca"] ?? cfg["ca[]"]);
const cafile = ensureSingleOrNone(cfg["cafile"]);
if (typeof cafile !== "undefined" && cafile !== "null") ca.push(...(await readCafile(cafile)));
return { proxy, noProxy, strictSSL, cert, ca: ca.length === 0 ? undefined : ca };
}
export async function downloadAndUnzip(
params: {
url: string;
destDirPath: string;
specificDirsToExtract?: string[];
preCacheTransform?: {
actionCacheId: string;
action: (params: { destDirPath: string }) => Promise<void>;
};
} & (
| {
doUseCache: true;
projectDirPath: string;
}
| {
doUseCache: false;
}
)
) {
const { url, destDirPath, specificDirsToExtract, preCacheTransform, ...rest } = params;
const zipFileBasename = generateFileNameFromURL({
url,
"preCacheTransform":
preCacheTransform === undefined
? undefined
: {
"actionCacheId": preCacheTransform.actionCacheId,
"actionFootprint": preCacheTransform.action.toString()
}
});
const cacheRoot = !rest.doUseCache
? `tmp_${Math.random().toString().slice(2, 12)}`
: pathJoin(process.env.XDG_CACHE_HOME ?? pathJoin(rest.projectDirPath, "node_modules", ".cache"), "keycloakify");
const zipFilePath = pathJoin(cacheRoot, `${zipFileBasename}.zip`);
const extractDirPath = pathJoin(cacheRoot, `tmp_unzip_${zipFileBasename}`);
if (!(await exists(zipFilePath))) {
const opts = await getFetchOptions();
const response = await fetch(url, opts);
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);
if (specificDirsToExtract !== undefined || preCacheTransform !== undefined) {
await unzip(zipFilePath, extractDirPath, specificDirsToExtract);
await preCacheTransform?.action({
"destDirPath": extractDirPath
});
await unlink(zipFilePath);
await zip(extractDirPath, zipFilePath);
await rm(extractDirPath, { "recursive": true });
}
}
await unzip(zipFilePath, extractDirPath);
transformCodebase({
"srcDirPath": extractDirPath,
"destDirPath": destDirPath
});
if (!rest.doUseCache) {
await rm(cacheRoot, { "recursive": true });
} else {
await rm(extractDirPath, { "recursive": true });
}
}

View File

@ -0,0 +1,73 @@
import { exec as execCallback } from "child_process";
import { readFile } from "fs/promises";
import { type FetchOptions } from "make-fetch-happen";
import { promisify } from "util";
function ensureArray<T>(arg0: T | T[]) {
return Array.isArray(arg0) ? arg0 : typeof arg0 === "undefined" ? [] : [arg0];
}
function ensureSingleOrNone<T>(arg0: T | T[]) {
if (!Array.isArray(arg0)) return arg0;
if (arg0.length === 0) return undefined;
if (arg0.length === 1) return arg0[0];
throw new Error("Illegal configuration, expected a single value but found multiple: " + arg0.map(String).join(", "));
}
type NPMConfig = Record<string, string | string[]>;
/**
* Get npm configuration as map
*/
async function getNmpConfig(params: { npmWorkspaceRootDirPath: string }) {
const { npmWorkspaceRootDirPath } = params;
const exec = promisify(execCallback);
const stdout = await exec("npm config get", { "encoding": "utf8", "cwd": npmWorkspaceRootDirPath }).then(({ stdout }) => stdout);
const npmConfigReducer = (cfg: NPMConfig, [key, value]: [string, string]) =>
key in cfg ? { ...cfg, [key]: [...ensureArray(cfg[key]), value] } : { ...cfg, [key]: value };
return stdout
.split("\n")
.filter(line => !line.startsWith(";"))
.map(line => line.trim())
.map(line => line.split("=", 2) as [string, string])
.reduce(npmConfigReducer, {} as NPMConfig);
}
export type ProxyFetchOptions = Pick<FetchOptions, "proxy" | "noProxy" | "strictSSL" | "cert" | "ca">;
export async function getProxyFetchOptions(params: { npmWorkspaceRootDirPath: string }): Promise<ProxyFetchOptions> {
const { npmWorkspaceRootDirPath } = params;
const cfg = await getNmpConfig({ npmWorkspaceRootDirPath });
const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]);
const noProxy = cfg["noproxy"] ?? cfg["no-proxy"];
function maybeBoolean(arg0: string | undefined) {
return typeof arg0 === "undefined" ? undefined : Boolean(arg0);
}
const strictSSL = maybeBoolean(ensureSingleOrNone(cfg["strict-ssl"]));
const cert = cfg["cert"];
const ca = ensureArray(cfg["ca"] ?? cfg["ca[]"]);
const cafile = ensureSingleOrNone(cfg["cafile"]);
if (typeof cafile !== "undefined" && cafile !== "null") {
ca.push(
...(await (async () => {
function chunks<T>(arr: T[], size: number = 2) {
return arr.map((_, i) => i % size == 0 && arr.slice(i, i + size)).filter(Boolean) as T[][];
}
const cafileContent = await readFile(cafile, "utf-8");
return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(ca => ca.join("").replace(/^\n/, "").replace(/\n/g, "\\n"));
})())
);
}
return { proxy, noProxy, strictSSL, cert, "ca": ca.length === 0 ? undefined : ca };
}

View File

@ -0,0 +1,11 @@
import * as fs from "fs/promises";
export async function existsAsync(path: string) {
try {
await fs.stat(path);
return true;
} catch (error) {
if ((error as Error & { code: string }).code === "ENOENT") return false;
throw error;
}
}

43
src/bin/tools/fs.rm.ts Normal file
View File

@ -0,0 +1,43 @@
import * as fs from "fs/promises";
import { join as pathJoin } from "path";
import { SemVer } from "./SemVer";
/**
* Polyfill of fs.rm(dirPath, { "recursive": true })
* For older version of Node
*/
export async function rm(dirPath: string, options: { recursive: true; force?: true }) {
if (SemVer.compare(SemVer.parse(process.version), SemVer.parse("14.14.0")) > 0) {
return fs.rm(dirPath, options);
}
const { force = true } = options;
if (force && !(await checkDirExists(dirPath))) {
return;
}
const removeDir_rec = async (dirPath: string) =>
Promise.all(
(await fs.readdir(dirPath)).map(async basename => {
const fileOrDirpath = pathJoin(dirPath, basename);
if ((await fs.lstat(fileOrDirpath)).isDirectory()) {
await removeDir_rec(fileOrDirpath);
} else {
await fs.unlink(fileOrDirpath);
}
})
);
await removeDir_rec(dirPath);
}
async function checkDirExists(dirPath: string) {
try {
await fs.access(dirPath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,34 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { SemVer } from "./SemVer";
/**
* Polyfill of fs.rmSync(dirPath, { "recursive": true })
* For older version of Node
*/
export function rmSync(dirPath: string, options: { recursive: true; force?: true }) {
if (SemVer.compare(SemVer.parse(process.version), SemVer.parse("14.14.0")) > 0) {
fs.rmSync(dirPath, options);
return;
}
const { force = true } = options;
if (force && !fs.existsSync(dirPath)) {
return;
}
const removeDir_rec = (dirPath: string) =>
fs.readdirSync(dirPath).forEach(basename => {
const fileOrDirpath = pathJoin(dirPath, basename);
if (fs.lstatSync(fileOrDirpath).isDirectory()) {
removeDir_rec(fileOrDirpath);
return;
} else {
fs.unlinkSync(fileOrDirpath);
}
});
removeDir_rec(dirPath);
}

View File

@ -0,0 +1,17 @@
import { isAbsolute as pathIsAbsolute, sep as pathSep, join as pathJoin } from "path";
export function getAbsoluteAndInOsFormatPath(params: { pathIsh: string; cwd: string }): string {
const { pathIsh, cwd } = params;
let pathOut = pathIsh;
pathOut = pathOut.replace(/\//g, pathSep);
pathOut = pathOut.endsWith(pathSep) ? pathOut.slice(0, -1) : pathOut;
if (!pathIsAbsolute(pathOut)) {
pathOut = pathJoin(cwd, pathOut);
}
return pathOut;
}

View File

@ -1,19 +1,19 @@
import * as fs from "fs";
import * as path from "path";
function getProjectRootRec(dirPath: string): string {
function getThisCodebaseRootDirPath_rec(dirPath: string): string {
if (fs.existsSync(path.join(dirPath, "package.json"))) {
return dirPath;
}
return getProjectRootRec(path.join(dirPath, ".."));
return getThisCodebaseRootDirPath_rec(path.join(dirPath, ".."));
}
let result: string | undefined = undefined;
export function getProjectRoot(): string {
export function getThisCodebaseRootDirPath(): string {
if (result !== undefined) {
return result;
}
return (result = getProjectRootRec(__dirname));
return (result = getThisCodebaseRootDirPath_rec(__dirname));
}

View File

@ -1,13 +1,15 @@
import { getProjectRoot } from "./getProjectRoot";
import { getThisCodebaseRootDirPath } from "./getThisCodebaseRootDirPath";
import { join as pathJoin } from "path";
import { constants } from "fs";
import { chmod, stat } from "fs/promises";
(async () => {
const { bin } = await import(pathJoin(getProjectRoot(), "package.json"));
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
const { bin } = await import(pathJoin(thisCodebaseRootDirPath, "package.json"));
const promises = Object.values<string>(bin).map(async scriptPath => {
const fullPath = pathJoin(getProjectRoot(), scriptPath);
const fullPath = pathJoin(thisCodebaseRootDirPath, scriptPath);
const oldMode = (await stat(fullPath)).mode;
const newMode = oldMode | constants.S_IXUSR | constants.S_IXGRP | constants.S_IXOTH;
await chmod(fullPath, newMode);

View File

@ -1,99 +0,0 @@
import { dirname, relative, sep, join } from "path";
import { createWriteStream } from "fs";
import walk from "./walk";
import { ZipFile } from "yazl";
import { mkdir } from "fs/promises";
import trimIndent from "./trimIndent";
export type ZipEntry = { zipPath: string } & ({ fsPath: string } | { buffer: Buffer });
export type ZipEntryGenerator = AsyncGenerator<ZipEntry, void, unknown>;
type CommonJarArgs = {
groupId: string;
artifactId: string;
version: string;
};
export type JarStreamArgs = CommonJarArgs & {
asyncPathGeneratorFn(): ZipEntryGenerator;
};
export type JarArgs = CommonJarArgs & {
targetPath: string;
rootPath: string;
};
export async function jarStream({ groupId, artifactId, version, asyncPathGeneratorFn }: JarStreamArgs) {
const manifestPath = "META-INF/MANIFEST.MF";
const manifestData = Buffer.from(trimIndent`
Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Created-By: Keycloakify
Built-By: unknown
Build-Jdk: 19.0.0
`);
const pomPropsPath = `META-INF/maven/${groupId}/${artifactId}/pom.properties`;
const pomPropsData = Buffer.from(trimIndent`
# Generated by keycloakify
# ${new Date()}
artifactId=${artifactId}
groupId=${groupId}
version=${version}
`);
const zipFile = new ZipFile();
for await (const entry of asyncPathGeneratorFn()) {
if ("buffer" in entry) {
zipFile.addBuffer(entry.buffer, entry.zipPath);
} else if ("fsPath" in entry) {
if (entry.fsPath.endsWith(sep)) {
zipFile.addEmptyDirectory(entry.zipPath);
} else {
zipFile.addFile(entry.fsPath, entry.zipPath);
}
}
}
zipFile.addBuffer(manifestData, manifestPath);
zipFile.addBuffer(pomPropsData, pomPropsPath);
zipFile.end();
return zipFile;
}
/**
* Create a jar archive, using the resources found at `rootPath` (a directory) and write the
* archive to `targetPath` (a file). Use `groupId`, `artifactId` and `version` to define
* the contents of the pom.properties file which is going to be added to the archive.
* The root directory is expectedto have a conventional maven/gradle folder structure with a
* single `pom.xml` file at the root and a `src/main/resources` directory containing all
* application resources.
*/
export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) {
await mkdir(dirname(targetPath), { recursive: true });
const asyncPathGeneratorFn = async function* (): ZipEntryGenerator {
const resourcesPath = join(rootPath, "src", "main", "resources");
for await (const fsPath of walk(resourcesPath)) {
const zipPath = relative(resourcesPath, fsPath).split(sep).join("/");
yield { fsPath, zipPath };
}
yield {
fsPath: join(rootPath, "pom.xml"),
zipPath: `META-INF/maven/${groupId}/${artifactId}/pom.xml`
};
};
const zipFile = await jarStream({ groupId, artifactId, version, asyncPathGeneratorFn });
await new Promise<void>(async (resolve, reject) => {
zipFile.outputStream
.pipe(createWriteStream(targetPath, { encoding: "binary" }))
.on("close", () => resolve())
.on("error", e => reject(e));
});
}

View File

@ -1,39 +1,39 @@
import { listTagsFactory } from "./listTags";
import type { Octokit } from "@octokit/rest";
import { NpmModuleVersion } from "../NpmModuleVersion";
import { SemVer } from "../SemVer";
export function getLatestsSemVersionedTagFactory(params: { octokit: Octokit }) {
const { octokit } = params;
async function getLatestsSemVersionedTag(params: { owner: string; repo: string; doIgnoreBeta: boolean; count: number }): Promise<
async function getLatestsSemVersionedTag(params: { owner: string; repo: string; count: number }): Promise<
{
tag: string;
version: NpmModuleVersion;
version: SemVer;
}[]
> {
const { owner, repo, doIgnoreBeta, count } = params;
const { owner, repo, count } = params;
const semVersionedTags: { tag: string; version: NpmModuleVersion }[] = [];
const semVersionedTags: { tag: string; version: SemVer }[] = [];
const { listTags } = listTagsFactory({ octokit });
for await (const tag of listTags({ owner, repo })) {
let version: NpmModuleVersion;
let version: SemVer;
try {
version = NpmModuleVersion.parse(tag.replace(/^[vV]?/, ""));
version = SemVer.parse(tag.replace(/^[vV]?/, ""));
} catch {
continue;
}
if (doIgnoreBeta && version.betaPreRelease !== undefined) {
if (version.rc !== undefined) {
continue;
}
semVersionedTags.push({ tag, version });
}
return semVersionedTags.sort(({ version: vX }, { version: vY }) => NpmModuleVersion.compare(vY, vX)).slice(0, count);
return semVersionedTags.sort(({ version: vX }, { version: vY }) => SemVer.compare(vY, vX)).slice(0, count);
}
return { getLatestsSemVersionedTag };

View File

@ -1,6 +0,0 @@
export function pathJoin(...path: string[]): string {
return path
.map((part, i) => (i === 0 ? part : part.replace(/^\/+/, "")))
.map((part, i) => (i === path.length - 1 ? part : part.replace(/\/+$/, "")))
.join("/");
}

View File

@ -0,0 +1,12 @@
import { getThisCodebaseRootDirPath } from "./getThisCodebaseRootDirPath";
import { assert } from "tsafe/assert";
import * as fs from "fs";
import { join as pathJoin } from "path";
export function readThisNpmProjectVersion(): string {
const version = JSON.parse(fs.readFileSync(pathJoin(getThisCodebaseRootDirPath(), "package.json")).toString("utf8"))["version"];
assert(typeof version === "string");
return version;
}

View File

@ -1,7 +1,7 @@
import * as fs from "fs";
import * as path from "path";
import { crawl } from "./crawl";
import { id } from "tsafe/id";
import { rmSync } from "../tools/fs.rmSync";
type TransformSourceCode = (params: { sourceCode: Buffer; filePath: string; fileRelativePath: string }) =>
| {
@ -10,18 +10,37 @@ type TransformSourceCode = (params: { sourceCode: Buffer; filePath: string; file
}
| undefined;
/** Apply a transformation function to every file of directory */
/**
* Apply a transformation function to every file of directory
* If source and destination are the same this function can be used to apply the transformation in place
* like filtering out some files or modifying them.
* */
export function transformCodebase(params: { srcDirPath: string; destDirPath: string; transformSourceCode?: TransformSourceCode }) {
const {
srcDirPath,
destDirPath,
transformSourceCode = id<TransformSourceCode>(({ sourceCode }) => ({
"modifiedSourceCode": sourceCode
}))
} = params;
const { srcDirPath, transformSourceCode } = params;
const isTargetSameAsSource = path.relative(srcDirPath, params.destDirPath) === "";
const destDirPath = isTargetSameAsSource ? path.join(srcDirPath, "..", "tmp_xOsPdkPsTdzPs34sOkHs") : params.destDirPath;
fs.mkdirSync(destDirPath, {
"recursive": true
});
for (const fileRelativePath of crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" })) {
const filePath = path.join(srcDirPath, fileRelativePath);
const destFilePath = path.join(destDirPath, fileRelativePath);
// NOTE: Optimization, if we don't need to transform the file, just copy
// it using the lower level implementation.
if (transformSourceCode === undefined) {
fs.mkdirSync(path.dirname(destFilePath), {
"recursive": true
});
fs.copyFileSync(filePath, destFilePath);
continue;
}
const transformSourceCodeResult = transformSourceCode({
"sourceCode": fs.readFileSync(filePath),
@ -33,15 +52,18 @@ export function transformCodebase(params: { srcDirPath: string; destDirPath: str
continue;
}
fs.mkdirSync(path.dirname(path.join(destDirPath, fileRelativePath)), {
fs.mkdirSync(path.dirname(destFilePath), {
"recursive": true
});
const { newFileName, modifiedSourceCode } = transformSourceCodeResult;
fs.writeFileSync(
path.join(path.dirname(path.join(destDirPath, fileRelativePath)), newFileName ?? path.basename(fileRelativePath)),
modifiedSourceCode
);
fs.writeFileSync(path.join(path.dirname(destFilePath), newFileName ?? path.basename(destFilePath)), modifiedSourceCode);
}
if (isTargetSameAsSource) {
rmSync(srcDirPath, { "recursive": true });
fs.renameSync(destDirPath, srcDirPath);
}
}

View File

@ -1,19 +0,0 @@
import { readdir } from "fs/promises";
import { resolve, sep } from "path";
/**
* Asynchronously and recursively walk a directory tree, yielding every file and directory
* found. Directory paths will _always_ end with a path separator.
*
* @param root the starting directory
* @returns AsyncGenerator
*/
export default async function* walk(root: string): AsyncGenerator<string, void, unknown> {
for (const entry of await readdir(root, { withFileTypes: true })) {
const absolutePath = resolve(root, entry.name);
if (entry.isDirectory()) {
yield absolutePath.endsWith(sep) ? absolutePath : absolutePath + sep;
yield* walk(absolutePath);
} else yield absolutePath.endsWith(sep) ? absolutePath.substring(0, absolutePath.length - 1) : absolutePath;
}
}

44
src/lib/BASE_URL.ts Normal file
View File

@ -0,0 +1,44 @@
import { assert } from "tsafe/assert";
/**
* WARNING: Internal use only!!
* THIS DOES NOT WORK IN KEYCLOAK! It's only for resolving mock assets.
* This is just a way to know what's the base url that works
* both in webpack and vite.
* You can see this as a polyfill that return `import.meta.env.BASE_URL` when in Vite
* and when in Webpack returns the base url in the same format as vite does meaning
* "/" if hosted at root or "/foo/" when hosted under a subpath (always start and ends with a "/").
*/
export const BASE_URL = (() => {
vite: {
let BASE_URL: string;
try {
// @ts-expect-error
BASE_URL = import.meta.env.BASE_URL;
assert(typeof BASE_URL === "string");
} catch {
break vite;
}
return BASE_URL;
}
webpack: {
let BASE_URL: string;
try {
// @ts-expect-error
BASE_URL = process.env.PUBLIC_URL;
assert(typeof BASE_URL === "string");
} catch {
break webpack;
}
return BASE_URL === "" ? "/" : `${BASE_URL}/`;
}
throw new Error("Bundler not supported");
})();

1
src/lib/isStorybook.ts Normal file
View File

@ -0,0 +1 @@
export const isStorybook = typeof window === "object" && Object.keys(window).find(key => key.startsWith("__STORYBOOK")) !== undefined;

View File

@ -36,6 +36,10 @@ export declare namespace keycloak_js {
}
/**
* @deprecated: This will be removed in the next major version.
* If you use this, please copy paste the code into your project.
* Better yet migrate away from keycloak-js and use https://docs.oidc-spa.dev instead.
*
* NOTE: This is just a slightly modified version of the default adapter in keycloak-js
* The goal here is just to be able to inject search param in url before keycloak redirect.
* Our use case for it is to pass over the login screen the states of useGlobalState

View File

@ -45,6 +45,8 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
return null;
}
document.title = i18n.msgStr("loginTitle", kcContext.realm.displayName);
return (
<div className={getClassName("kcLoginClass")}>
<div id="kc-header" className={getClassName("kcHeaderClass")}>

View File

@ -95,4 +95,5 @@ export type ClassKey =
| "kcAuthenticatorOtpCircleClass"
| "kcSelectOTPItemHeadingClass"
| "kcFormOptionsWrapperClass"
| "kcFormButtonsWrapperClass";
| "kcFormButtonsWrapperClass"
| "kcInputGroup";

View File

@ -1,4 +1,5 @@
import type { LoginThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl";
import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
import { type ThemeType } from "keycloakify/bin/constants";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import type { MessageKey } from "../i18n/i18n";
@ -39,6 +40,7 @@ export type KcContext =
export declare namespace KcContext {
export type Common = {
themeVersion: string;
keycloakifyVersion: string;
themeType: "login";
themeName: string;
@ -80,8 +82,9 @@ export declare namespace KcContext {
clientId: string;
name?: string;
description?: string;
attributes: Record<string, string>;
};
isAppInitiatedAction: boolean;
isAppInitiatedAction?: boolean;
messagesPerField: {
/**
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.

View File

@ -2,14 +2,12 @@ import type { KcContext, Attribute } from "./KcContext";
import { kcContextMocks, kcContextCommonMock } from "./kcContextMocks";
import type { DeepPartial } from "keycloakify/tools/DeepPartial";
import { deepAssign } from "keycloakify/tools/deepAssign";
import { isStorybook } from "keycloakify/lib/isStorybook";
import { id } from "tsafe/id";
import { exclude } from "tsafe/exclude";
import { assert } from "tsafe/assert";
import type { ExtendKcContext } from "./getKcContextFromWindow";
import { getKcContextFromWindow } from "./getKcContextFromWindow";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { pathBasename } from "keycloakify/tools/pathBasename";
import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { symToStr } from "tsafe/symToStr";
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
@ -32,7 +30,13 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
if (mockPageId !== undefined && realKcContext === undefined) {
//TODO maybe trow if no mock fo custom page
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
warn_that_mock_is_enbaled: {
if (isStorybook) {
break warn_that_mock_is_enbaled;
}
console.log(`%cKeycloakify: ${symToStr({ mockPageId })} set to ${mockPageId}.`, "background: red; color: yellow; font-size: medium");
}
const kcContextDefaultMock = kcContextMocks.find(({ pageId }) => pageId === mockPageId);
@ -148,12 +152,6 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
return { "kcContext": undefined as any };
}
{
const { url } = realKcContext;
url.resourcesCommonPath = pathJoin(url.resourcesPath, pathBasename(resourcesCommonDirPathRelativeToPublicDir));
}
return { "kcContext": realKcContext as any };
}

View File

@ -1,11 +1,11 @@
import type { KcContext } from "./KcContext";
import type { AndByDiscriminatingKey } from "keycloakify/tools/AndByDiscriminatingKey";
import { ftlValuesGlobalName } from "keycloakify/bin/keycloakify/ftlValuesGlobalName";
import { nameOfTheGlobal } from "keycloakify/bin/constants";
export type ExtendKcContext<KcContextExtension extends { pageId: string }> = [KcContextExtension] extends [never]
? KcContext
: AndByDiscriminatingKey<"pageId", KcContextExtension & KcContext.Common, KcContext>;
export function getKcContextFromWindow<KcContextExtension extends { pageId: string } = never>(): ExtendKcContext<KcContextExtension> | undefined {
return typeof window === "undefined" ? undefined : (window as any)[ftlValuesGlobalName];
return typeof window === "undefined" ? undefined : (window as any)[nameOfTheGlobal];
}

View File

@ -1,12 +1,10 @@
import "minimal-polyfills/Object.fromEntries";
import type { KcContext, Attribute } from "./KcContext";
import { resourcesCommonDirPathRelativeToPublicDir, resourcesDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath";
import { pathJoin } from "keycloakify/bin/tools/pathJoin";
import { resources_common, keycloak_resources } from "keycloakify/bin/constants";
import { id } from "tsafe/id";
import { assert, type Equals } from "tsafe/assert";
import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl";
const PUBLIC_URL = (typeof process !== "object" ? undefined : process.env?.["PUBLIC_URL"]) || "/";
import { BASE_URL } from "keycloakify/lib/BASE_URL";
const attributes: Attribute[] = [
{
@ -102,14 +100,17 @@ const attributes: Attribute[] = [
const attributesByName = Object.fromEntries(attributes.map(attribute => [attribute.name, attribute])) as any;
const resourcesPath = `${BASE_URL}${keycloak_resources}/login/resources`;
export const kcContextCommonMock: KcContext.Common = {
"themeVersion": "0.0.0",
"keycloakifyVersion": "0.0.0",
"themeType": "login",
"themeName": "my-theme-name",
"url": {
"loginAction": "#",
"resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir),
"resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir),
resourcesPath,
"resourcesCommonPath": `${resourcesPath}/${resources_common}`,
"loginRestartFlowUrl": "/auth/realms/myrealm/login-actions/restart?client_id=account&tab_id=HoAx28ja4xg",
"loginUrl": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg"
},
@ -231,7 +232,8 @@ export const kcContextCommonMock: KcContext.Common = {
"showTryAnotherWayLink": false
},
"client": {
"clientId": "myApp"
"clientId": "myApp",
"attributes": {}
},
"scripts": [],
"isAppInitiatedAction": false
@ -311,7 +313,8 @@ export const kcContextMocks = [
"actionUri": "#",
"client": {
"clientId": "myApp",
"baseUrl": "#"
"baseUrl": "#",
"attributes": {}
}
}),
id<KcContext.Error>({
@ -319,7 +322,8 @@ export const kcContextMocks = [
"pageId": "error.ftl",
"client": {
"clientId": "myApp",
"baseUrl": "#"
"baseUrl": "#",
"attributes": {}
},
"message": {
"type": "error",
@ -493,7 +497,8 @@ export const kcContextMocks = [
},
"client": {
"clientId": "myApp",
"baseUrl": "#"
"baseUrl": "#",
"attributes": {}
},
"logoutConfirm": { "code": "123", skipLink: false }
}),

View File

@ -67,6 +67,7 @@ export const { useGetClassName } = createUseClassName<ClassKey>({
// css classes for input
"kcInputLargeClass": "input-lg",
"kcInputGroup": "pf-c-input-group",
// css classes for form accessability
"kcSrOnlyClass": "sr-only",
@ -93,7 +94,7 @@ export const { useGetClassName } = createUseClassName<ClassKey>({
"kcAuthenticatorWebAuthnPasswordlessClass": "fa fa-key list-view-pf-icon-lg",
//css classes for the OTP Login Form
"kcSelectOTPListClass": "card-pf card-pf-view card-pf-view-select card-pf-view-single-select",
"kcSelectOTPListClass": "card-pf card-pf-view card-pf-view-select card-pf-view-single-select col-xs-12",
"kcSelectOTPListItemClass": "card-pf-body card-pf-top-element",
"kcAuthenticatorOtpCircleClass": "fa fa-mobile card-pf-icon-circle",
"kcSelectOTPItemHeadingClass": "card-pf-title text-center",

View File

@ -8,7 +8,10 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
const { msgStr, msg } = i18n;
assert(kcContext.message !== undefined);
assert(
kcContext.message !== undefined,
"No message in kcContext.message, there will always be a message in production context, add it in your mock"
);
const { messageHeader, message, requiredActions, skipLink, pageRedirectUri, actionUri, client } = kcContext;

View File

@ -37,22 +37,18 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
return (
<Template
{...{ kcContext, i18n, doUseDefaultCss, classes }}
displayInfo={social.displayInfo}
displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled}
displayWide={realm.password && social.providers !== undefined}
headerNode={msg("doLogIn")}
infoNode={
realm.password &&
realm.registrationAllowed &&
!registrationDisabled && (
<div id="kc-registration">
<span>
{msg("noAccount")}
<a tabIndex={6} href={url.registrationUrl}>
{msg("doRegister")}
</a>
</span>
</div>
)
<div id="kc-registration">
<span>
{msg("noAccount")}
<a tabIndex={6} href={url.registrationUrl}>
{msg("doRegister")}
</a>
</span>
</div>
}
>
<div id="kc-form" className={clsx(realm.password && social.providers !== undefined && getClassName("kcContentWrapperClass"))}>

View File

@ -1,12 +1,11 @@
import { clsx } from "keycloakify/tools/clsx";
import Template from "../Template";
import { I18n } from "../i18n";
import { KcContext } from "../kcContext";
import { useGetClassName } from "../lib/useGetClassName";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { PageProps } from "./PageProps";
export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pageId: "login-oauth2-device-verify-user-code.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes } = props;
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { url } = kcContext;
const { msg, msgStr } = i18n;

View File

@ -2,11 +2,10 @@ import { clsx } from "keycloakify/tools/clsx";
import { PageProps } from "./PageProps";
import { KcContext } from "../kcContext";
import { I18n } from "../i18n";
import Template from "../Template";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pageId: "login-oauth-grant.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes } = props;
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { url, oauth, client } = kcContext;
const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n;

View File

@ -1,5 +1,3 @@
import { useEffect } from "react";
import { headInsert } from "keycloakify/tools/headInsert";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
@ -18,105 +16,77 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
const { msg, msgStr } = i18n;
useEffect(() => {
let isCleanedUp = false;
const { prLoaded, remove } = headInsert({
"type": "javascript",
"src": `${kcContext.url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js`
});
(async () => {
await prLoaded;
if (isCleanedUp) {
return;
}
evaluateInlineScript();
})();
return () => {
isCleanedUp = true;
remove();
};
}, []);
return (
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("doLogIn")}>
<form id="kc-otp-login-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
{otpLogin.userOtpCredentials.length > 1 && (
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcInputWrapperClass")}>
{otpLogin.userOtpCredentials.map(otpCredential => (
<div key={otpCredential.id} className={getClassName("kcSelectOTPListClass")}>
<input type="hidden" value="${otpCredential.id}" />
<div className={getClassName("kcSelectOTPListItemClass")}>
<span className={getClassName("kcAuthenticatorOtpCircleClass")} />
<h2 className={getClassName("kcSelectOTPItemHeadingClass")}>{otpCredential.userLabel}</h2>
<>
<style>
{`
input[type="radio"]:checked~label.kcSelectOTPListClass{
border: 2px solid #39a5dc;
}`}
</style>
<Template {...{ kcContext, i18n, doUseDefaultCss, classes }} headerNode={msg("doLogIn")}>
<form id="kc-otp-login-form" className={getClassName("kcFormClass")} action={url.loginAction} method="post">
{otpLogin.userOtpCredentials.length > 1 && (
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcInputWrapperClass")}>
{otpLogin.userOtpCredentials.map((otpCredential, index) => (
<div key={otpCredential.id}>
<input
id={`kc-otp-credential-${index}`}
name="selectedCredentialId"
type="radio"
value={otpCredential.id}
style={{ display: "none" }}
/>
<label
htmlFor={`kc-otp-credential-${index}`}
key={otpCredential.id}
className={getClassName("kcSelectOTPListClass")}
>
<div className={getClassName("kcSelectOTPListItemClass")}>
<span className={getClassName("kcAuthenticatorOtpCircleClass")} />
<h2 className={getClassName("kcSelectOTPItemHeadingClass")}>{otpCredential.userLabel}</h2>
</div>
</label>
</div>
</div>
))}
))}
</div>
</div>
)}
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="otp" className={getClassName("kcLabelClass")}>
{msg("loginOtpOneTime")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input id="otp" name="otp" autoComplete="off" type="text" className={getClassName("kcInputClass")} autoFocus />
</div>
</div>
)}
<div className={getClassName("kcFormGroupClass")}>
<div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor="otp" className={getClassName("kcLabelClass")}>
{msg("loginOtpOneTime")}
</label>
</div>
<div className={getClassName("kcInputWrapperClass")}>
<input id="otp" name="otp" autoComplete="off" type="text" className={getClassName("kcInputClass")} autoFocus />
</div>
</div>
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")} />
</div>
<div className={getClassName("kcFormGroupClass")}>
<div id="kc-form-options" className={getClassName("kcFormOptionsClass")}>
<div className={getClassName("kcFormOptionsWrapperClass")} />
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
name="login"
id="kc-login"
type="submit"
value={msgStr("doLogIn")}
/>
</div>
</div>
<div id="kc-form-buttons" className={getClassName("kcFormButtonsClass")}>
<input
className={clsx(
getClassName("kcButtonClass"),
getClassName("kcButtonPrimaryClass"),
getClassName("kcButtonBlockClass"),
getClassName("kcButtonLargeClass")
)}
name="login"
id="kc-login"
type="submit"
value={msgStr("doLogIn")}
/>
</div>
</div>
</form>
</Template>
</form>
</Template>
</>
);
}
declare const $: any;
function evaluateInlineScript() {
$(document).ready(function () {
// Card Single Select
$(".card-pf-view-single-select").click(function (this: any) {
if ($(this).hasClass("active")) {
$(this).removeClass("active");
$(this).children().removeAttr("name");
} else {
$(".card-pf-view-single-select").removeClass("active");
$(".card-pf-view-single-select").children().removeAttr("name");
$(this).addClass("active");
$(this).children().attr("name", "selectedCredentialId");
}
});
var defaultCred = $(".card-pf-view-single-select")[0];
if (defaultCred) {
defaultCred.click();
}
});
}

View File

@ -1,10 +1,11 @@
import type { LazyExoticComponent } from "react";
import type { I18n } from "keycloakify/login/i18n";
import { type TemplateProps, type ClassKey } from "keycloakify/login/TemplateProps";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { KcContext } from "keycloakify/account/kcContext";
export type PageProps<KcContext, I18nExtended extends I18n> = {
Template: LazyExoticComponent<(props: TemplateProps<any, any>) => JSX.Element | null>;
kcContext: KcContext;
export type PageProps<NarowedKcContext = KcContext, I18nExtended extends I18n = I18n> = {
Template: LazyOrNot<(props: TemplateProps<any, any>) => JSX.Element | null>;
kcContext: NarowedKcContext;
i18n: I18nExtended;
doUseDefaultCss: boolean;
classes?: Partial<Record<ClassKey, string>>;

3
src/tools/LazyOrNot.ts Normal file
View File

@ -0,0 +1,3 @@
import type { LazyExoticComponent, ComponentType } from "react";
export type LazyOrNot<Component extends ComponentType<any>> = LazyExoticComponent<Component> | Component;

View File

@ -14,7 +14,7 @@
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true
},
"exclude": ["./bin"],
"exclude": ["./bin", "./vite-plugin"],
"references": [
{
"path": "./bin"

1
src/vite-plugin/index.ts Normal file
View File

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

View File

@ -0,0 +1,18 @@
{
"extends": "../../tsproject.json",
"compilerOptions": {
"module": "CommonJS",
"target": "ES2019",
"esModuleInterop": true,
"lib": ["es2019", "es2020.bigint", "es2020.string", "es2020.symbol.wellknown"],
"outDir": "../../dist/vite-plugin",
"rootDir": ".",
// https://github.com/vitejs/vite/issues/15112#issuecomment-1823908010
"skipLibCheck": true
},
"references": [
{
"path": "../bin"
}
]
}

View File

@ -0,0 +1,192 @@
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
import type { Plugin } from "vite";
import * as fs from "fs";
import {
resolvedViteConfigJsonBasename,
nameOfTheGlobal,
basenameOfTheKeycloakifyResourcesDir,
keycloak_resources,
keycloakifyBuildOptionsForPostPostBuildScriptEnvName
} from "../bin/constants";
import type { ResolvedViteConfig } from "../bin/keycloakify/buildOptions/resolvedViteConfig";
import { getCacheDirPath } from "../bin/keycloakify/buildOptions/getCacheDirPath";
import { id } from "tsafe/id";
import { rm } from "../bin/tools/fs.rm";
import { copyKeycloakResourcesToPublic } from "../bin/copy-keycloak-resources-to-public";
import { assert } from "tsafe/assert";
import type { BuildOptions } from "../bin/keycloakify/buildOptions";
import type { UserProvidedBuildOptions } from "../bin/keycloakify/buildOptions/UserProvidedBuildOptions";
import MagicString from "magic-string";
export type Params = UserProvidedBuildOptions & {
postBuild?: (buildOptions: Omit<BuildOptions, "bundler">) => Promise<void>;
};
export function keycloakify(params?: Params) {
const { postBuild, ...userProvidedBuildOptions } = params ?? {};
let reactAppRootDirPath: string | undefined = undefined;
let urlPathname: string | undefined = undefined;
let buildDirPath: string | undefined = undefined;
let command: "build" | "serve" | undefined = undefined;
let shouldGenerateSourcemap: boolean | undefined = undefined;
const plugin = {
"name": "keycloakify" as const,
"configResolved": async resolvedConfig => {
shouldGenerateSourcemap = resolvedConfig.build.sourcemap !== false;
run_post_build_script: {
const buildOptionJson = process.env[keycloakifyBuildOptionsForPostPostBuildScriptEnvName];
if (buildOptionJson === undefined) {
break run_post_build_script;
}
if (postBuild === undefined) {
process.exit(0);
}
const buildOptions: BuildOptions = JSON.parse(buildOptionJson);
await postBuild(buildOptions);
process.exit(0);
}
command = resolvedConfig.command;
reactAppRootDirPath = resolvedConfig.root;
urlPathname = (() => {
let out = resolvedConfig.env.BASE_URL;
if (out.startsWith(".") && command === "build" && resolvedConfig.envPrefix?.includes("STORYBOOK_") !== true) {
throw new Error(
[
`BASE_URL=${out} is not supported By Keycloakify. Use an absolute URL instead.`,
`If this is a problem, please open an issue at https://github.com/keycloakify/keycloakify/issues/new`
].join("\n")
);
}
if (out === undefined) {
return undefined;
}
if (!out.startsWith("/")) {
out = "/" + out;
}
if (!out.endsWith("/")) {
out += "/";
}
return out;
})();
buildDirPath = pathJoin(reactAppRootDirPath, resolvedConfig.build.outDir);
const { cacheDirPath } = getCacheDirPath({
reactAppRootDirPath
});
if (!fs.existsSync(cacheDirPath)) {
fs.mkdirSync(cacheDirPath, { "recursive": true });
}
fs.writeFileSync(
pathJoin(cacheDirPath, resolvedViteConfigJsonBasename),
Buffer.from(
JSON.stringify(
id<ResolvedViteConfig>({
"publicDir": pathRelative(reactAppRootDirPath, resolvedConfig.publicDir),
"assetsDir": resolvedConfig.build.assetsDir,
"buildDir": resolvedConfig.build.outDir,
urlPathname,
userProvidedBuildOptions
}),
null,
2
),
"utf8"
)
);
await copyKeycloakResourcesToPublic({
"processArgv": ["--project", reactAppRootDirPath]
});
},
"transform": (code, id) => {
assert(command !== undefined);
assert(shouldGenerateSourcemap !== undefined);
if (command !== "build") {
return;
}
assert(reactAppRootDirPath !== undefined);
{
const isWithinSourceDirectory = id.startsWith(pathJoin(reactAppRootDirPath, "src") + pathSep);
if (!isWithinSourceDirectory) {
return;
}
}
{
const isJavascriptFile = id.endsWith(".js") || id.endsWith(".jsx");
const isTypeScriptFile = id.endsWith(".ts") || id.endsWith(".tsx");
if (!isTypeScriptFile && !isJavascriptFile) {
return;
}
}
const transformedCode = new MagicString(code);
transformedCode.replaceAll(
/import\.meta\.env(?:(?:\.BASE_URL)|(?:\["BASE_URL"\]))/g,
[
`(`,
`(window.${nameOfTheGlobal} === undefined || import.meta.env.MODE === "development")?`,
`"${urlPathname ?? "/"}":`,
`(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/")`,
`)`
].join("")
);
if (!transformedCode.hasChanged()) {
return;
}
if (!shouldGenerateSourcemap) {
return transformedCode.toString();
}
const map = transformedCode.generateMap({
"source": id,
"includeContent": true,
"hires": true
});
return {
"code": transformedCode.toString(),
"map": map.toString()
};
},
"closeBundle": async () => {
assert(command !== undefined);
if (command !== "build") {
return;
}
assert(buildDirPath !== undefined);
await rm(pathJoin(buildDirPath, keycloak_resources), { "recursive": true, "force": true });
}
} satisfies Plugin;
return plugin as any;
}

View File

@ -21,4 +21,13 @@ const meta: ComponentMeta<any> = {
export default meta;
export const Default = () => <PageStory />;
export const Default = () => (
<PageStory
kcContext={{
message: {
summary: "This is the server message",
type: "info"
}
}}
/>
);

View File

@ -1,131 +0,0 @@
import jar, { jarStream, type ZipEntryGenerator } from "keycloakify/bin/tools/jar";
import { fromBuffer, Entry, ZipFile } from "yauzl";
import { it, describe, assert, afterAll } from "vitest";
import { Readable } from "stream";
import { tmpdir } from "os";
import { mkdtemp, cp, mkdir, rm, writeFile } from "fs/promises";
import path from "path";
import { createReadStream } from "fs";
import walk from "keycloakify/bin/tools/walk";
type AsyncIterable<T> = {
[Symbol.asyncIterator](): AsyncIterableIterator<T>;
};
async function arrayFromAsync<T>(asyncIterable: AsyncIterable<T>) {
const chunks: T[] = [];
for await (const chunk of asyncIterable) chunks.push(chunk);
return chunks;
}
async function readToBuffer(stream: NodeJS.ReadableStream) {
return Buffer.concat(await arrayFromAsync(stream as AsyncIterable<Buffer>));
}
function unzipBuffer(buffer: Buffer) {
return new Promise<ZipFile>((resolve, reject) =>
fromBuffer(buffer, { lazyEntries: true }, (err, zipFile) => {
if (err !== null) {
reject(err);
} else {
resolve(zipFile);
}
})
);
}
function readEntry(zipFile: ZipFile, entry: Entry): Promise<Readable> {
return new Promise<Readable>((resolve, reject) => {
zipFile.openReadStream(entry, (err, stream) => {
if (err !== null) {
reject(err);
} else {
resolve(stream);
}
});
});
}
function readAll(zipFile: ZipFile): Promise<Map<string, Buffer>> {
return new Promise<Map<string, Buffer>>((resolve, reject) => {
const entries1: Map<string, Buffer> = new Map();
zipFile.on("entry", async (entry: Entry) => {
const stream = await readEntry(zipFile, entry);
const buffer = await readToBuffer(stream);
entries1.set(entry.fileName, buffer);
zipFile.readEntry();
});
zipFile.on("end", () => resolve(entries1));
zipFile.on("error", e => reject(e));
zipFile.readEntry();
});
}
describe("jar", () => {
const coords = { artifactId: "someArtifactId", groupId: "someGroupId", version: "1.2.3" };
const tmpDirs: string[] = [];
afterAll(async () => {
await Promise.all(tmpDirs.map(dir => rm(dir, { force: true, recursive: true })));
});
it("creates jar artifacts without error", async () => {
async function* mockFiles(): ZipEntryGenerator {
yield { zipPath: "foo", buffer: Buffer.from("foo") };
}
const zipped = await jarStream({ ...coords, asyncPathGeneratorFn: mockFiles });
const buffered = await readToBuffer(zipped.outputStream);
const unzipped = await unzipBuffer(buffered);
const entries = await readAll(unzipped);
assert.equal(entries.size, 3);
assert.isOk(entries.has("foo"));
assert.isOk(entries.has("META-INF/MANIFEST.MF"));
assert.isOk(entries.has("META-INF/maven/someGroupId/someArtifactId/pom.properties"));
assert.equal("foo", entries.get("foo")?.toString("utf-8"));
const manifest = entries.get("META-INF/MANIFEST.MF")?.toString("utf-8");
const pomProperties = entries.get("META-INF/maven/someGroupId/someArtifactId/pom.properties")?.toString("utf-8");
assert.isOk(manifest?.includes("Created-By: Keycloakify"));
assert.isOk(pomProperties?.includes("1.2.3"));
assert.isOk(pomProperties?.includes("someGroupId"));
assert.isOk(pomProperties?.includes("someArtifactId"));
});
it("creates a jar from _real_ files without error", async () => {
const tmp = await mkdtemp(path.join(tmpdir(), "kc-jar-test-"));
tmpDirs.push(tmp);
const rootPath = path.join(tmp, "root");
const resourcesPath = path.join(tmp, "root", "src", "main", "resources");
const targetPath = path.join(tmp, "jar.jar");
await mkdir(resourcesPath, { recursive: true });
await writeFile(path.join(rootPath, "pom.xml"), "foo", "utf-8");
await cp(path.dirname(__dirname), resourcesPath, { recursive: true });
await jar({ ...coords, rootPath, targetPath });
const buffered = await readToBuffer(createReadStream(targetPath));
const unzipped = await unzipBuffer(buffered);
const entries = await readAll(unzipped);
const zipPaths = Array.from(entries.keys());
assert.isOk(entries.has("META-INF/MANIFEST.MF"));
assert.isOk(entries.has("META-INF/maven/someGroupId/someArtifactId/pom.properties"));
assert.isOk(entries.has("META-INF/maven/someGroupId/someArtifactId/pom.xml"));
for await (const fsPath of walk(resourcesPath)) {
if (!fsPath.endsWith(path.sep)) {
const rel = path.relative(resourcesPath, fsPath).replace(path.sep === "/" ? /\//g : /\\/g, "/");
assert.isOk(zipPaths.includes(rel), `missing '${rel}' (${rel}, '${zipPaths.join("', '")}')`);
}
}
});
});

View File

@ -1,107 +0,0 @@
import { readPaths } from "keycloakify/bin/keycloakify/generateTheme/readStaticResourcesUsage";
import { same } from "evt/tools/inDepth/same";
import { expect, it, describe } from "vitest";
describe("Ensure it's able to extract used Keycloak resources", () => {
const expectedPaths = {
"resourcesCommonFilePaths": [
"node_modules/patternfly/dist/css/patternfly.min.css",
"node_modules/patternfly/dist/css/patternfly-additions.min.css",
"lib/zocial/zocial.css",
"node_modules/jquery/dist/jquery.min.js"
]
};
it("works with coding style n°1", () => {
const paths = readPaths({
"rawSourceFile": `
const { isReady } = usePrepareTemplate({
"doFetchDefaultThemeResources": doUseDefaultCss,
"styles": [
\`\${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css\`,
\`\${
url.resourcesCommonPath
}/node_modules/patternfly/dist/css/patternfly-additions.min.css\`,
\`\${resourcesCommonPath }/lib/zocial/zocial.css\`,
\`\${url.resourcesPath}/css/login.css\`
],
"htmlClassName": getClassName("kcHtmlClass"),
"bodyClassName": undefined
});
const { prLoaded, remove } = headInsert({
"type": "javascript",
"src": \`\${kcContext.url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js\`
});
`
});
expect(same(paths, expectedPaths)).toBe(true);
});
it("works with coding style n°2", () => {
const paths = readPaths({
"rawSourceFile": `
const { isReady } = usePrepareTemplate({
"doFetchDefaultThemeResources": doUseDefaultCss,
"styles": [
url.resourcesCommonPath + "/node_modules/patternfly/dist/css/patternfly.min.css",
url.resourcesCommonPath + '/node_modules/patternfly/dist/css/patternfly-additions.min.css',
url.resourcesCommonPath
+ "/lib/zocial/zocial.css",
url.resourcesPath +
'/css/login.css'
],
"htmlClassName": getClassName("kcHtmlClass"),
"bodyClassName": undefined
});
const { prLoaded, remove } = headInsert({
"type": "javascript",
"src": kcContext.url.resourcesCommonPath + "/node_modules/jquery/dist/jquery.min.js\"
});
`
});
console.log(paths);
console.log(expectedPaths);
expect(same(paths, expectedPaths)).toBe(true);
});
it("works with coding style n°3", () => {
const paths = readPaths({
"rawSourceFile": `
const { isReady } = usePrepareTemplate({
"doFetchDefaultThemeResources": doUseDefaultCss,
"styles": [
path.join(resourcesCommonPath,"/node_modules/patternfly/dist/css/patternfly.min.css"),
path.join(url.resourcesCommonPath, '/node_modules/patternfly/dist/css/patternfly-additions.min.css'),
path.join(url.resourcesCommonPath,
"/lib/zocial/zocial.css"),
pathJoin(
url.resourcesPath,
'css/login.css'
)
],
"htmlClassName": getClassName("kcHtmlClass"),
"bodyClassName": undefined
});
const { prLoaded, remove } = headInsert({
"type": "javascript",
"src": path.join(kcContext.url.resourcesCommonPath, "/node_modules/jquery/dist/jquery.min.js")
});
`
});
expect(same(paths, expectedPaths)).toBe(true);
});
});

View File

@ -1,389 +0,0 @@
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 { same } from "evt/tools/inDepth/same";
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) + "." + {
3: "0664cdc0"
}[e] + ".chunk.js"
}
function sameAsF() {
return a.p+"static/js/" + ({}[e] || e) + "." + {
3: "0664cdc0"
}[e] + ".chunk.js"
}
__webpack_require__.u=function(e){return"static/js/" + e + "." + {
147: "6c5cee76",
787: "8da10fcf",
922: "be170a73"
} [e] + ".chunk.js"
}
t.miniCssF=function(e){return"static/css/"+e+"."+{
164:"dcfd7749",
908:"67c9ed2c"
}[e]+".chunk.css"
}
n.u=e=>"static/js/"+e+"."+{69:"4f205f87",128:"49264537",453:"b2fed72e",482:"f0106901"}[e]+".chunk.js"
t.miniCssF=e=>"static/css/"+e+"."+{164:"dcfd7749",908:"67c9ed2c"}[e]+".chunk.css"
`;
it("transforms standalone code properly", () => {
const { fixedJsCode } = replaceImportsFromStaticInJsCode({
"jsCode": jsCodeUntransformed
});
const fixedJsCodeExpected = `
function f() {
return window.kcContext.url.resourcesPath + "/build/static/js/" + ({}[e] || e) + "." + {
3: "0664cdc0"
}[e] + ".chunk.js"
}
function sameAsF() {
return window.kcContext.url.resourcesPath + "/build/static/js/" + ({}[e] || e) + "." + {
3: "0664cdc0"
}[e] + ".chunk.js"
}
__webpack_require__[(function (){
var pd = Object.getOwnPropertyDescriptor(__webpack_require__, "p");
if( pd === undefined || pd.configurable ){
Object.defineProperty(__webpack_require__, "p", {
get: function() { return window.kcContext.url.resourcesPath; },
set: function() {}
});
}
return "u";
})()] = function(e) {
return "/build/static/js/" + e + "." + {
147: "6c5cee76",
787: "8da10fcf",
922: "be170a73"
} [e] + ".chunk.js"
}
t[(function (){
var pd = Object.getOwnPropertyDescriptor(t, "p");
if( pd === undefined || pd.configurable ){
Object.defineProperty(t, "p", {
get: function() { return window.kcContext.url.resourcesPath; },
set: function() {}
});
}
return "miniCssF";
})()] = function(e) {
return "/build/static/css/" + e + "." + {
164:"dcfd7749",
908:"67c9ed2c"
} [e] + ".chunk.css"
}
n[(function(){
var pd = Object.getOwnPropertyDescriptor(n, "p");
if( pd === undefined || pd.configurable ){
Object.defineProperty(n, "p", {
get: function() { return window.kcContext.url.resourcesPath; },
set: function() {}
});
}
return "u";
})()] = e => "/build/static/js/"+e+"."+{69:"4f205f87",128:"49264537",453:"b2fed72e",482:"f0106901"}[e]+".chunk.js"
t[(function(){
var pd = Object.getOwnPropertyDescriptor(t, "p");
if( pd === undefined || pd.configurable ){
Object.defineProperty(t, "p", {
get: function() { return window.kcContext.url.resourcesPath; },
set: function() {}
});
}
return "miniCssF";
})()] = e => "/build/static/css/"+e+"."+{164:"dcfd7749",908:"67c9ed2c"}[e]+".chunk.css"
`;
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
});
});
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: var(--url1f9ef5a892c104c);
}
.my-div2 {
background: var(--url1f9ef5a892c104c);
}
.my-div {
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(--urlf8277cddaa2be78);
}
.my-div2 {
background: var(--urlf8277cddaa2be78);
}
.my-div {
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);
});
});
describe("bin/css-inline-transforms", () => {
describe("no url pathName", () => {
const cssCode = `
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/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("/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("/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("/fonts/WorkSans/worksans-bold-webfont.woff2") format("woff2");
}
`;
it("transforms css for standalone app properly", () => {
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"urlPathname": undefined
}
});
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);
});
});
describe("with url pathName", () => {
const cssCode = `
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/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("/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("/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("/x/y/z/fonts/WorkSans/worksans-bold-webfont.woff2") format("woff2");
}
`;
it("transforms css for standalone app properly", () => {
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"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);
});
});
});

637
test/bin/replacers.spec.ts Normal file
View File

@ -0,0 +1,637 @@
import { replaceImportsInJsCode_vite } from "keycloakify/bin/keycloakify/replacers/replaceImportsInJsCode/vite";
import { replaceImportsInJsCode_webpack } from "keycloakify/bin/keycloakify/replacers/replaceImportsInJsCode/webpack";
import { generateCssCodeToDefineGlobals, replaceImportsInCssCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsInCssCode";
import { replaceImportsInInlineCssCode } from "keycloakify/bin/keycloakify/replacers/replaceImportsInInlineCssCode";
import { same } from "evt/tools/inDepth/same";
import { expect, it, describe } from "vitest";
import { isSameCode } from "../tools/isSameCode";
import { basenameOfTheKeycloakifyResourcesDir, nameOfTheGlobal } from "keycloakify/bin/constants";
describe("js replacer - vite", () => {
it("replaceImportsInJsCode_vite - 1", () => {
const before = `Uv="modulepreload",`;
const after = `,Wc={},`;
const jsCodeUntransformed = `${before}Hv=function(e){return"/foo-bar-baz/"+e}${after}`;
const { fixedJsCode } = replaceImportsInJsCode_vite({
"jsCode": jsCodeUntransformed,
"basenameOfAssetsFiles": [],
"buildOptions": {
"reactAppBuildDirPath": "/Users/someone/github/keycloakify-starter/dist/",
"assetsDirPath": "/Users/someone/github/keycloakify-starter/dist/assets/",
"urlPathname": "/foo-bar-baz/"
}
});
const fixedJsCodeExpected = `${before}Hv=function(e){return"/"+e}${after}`;
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
});
it("replaceImportsInJsCode_vite - 2", () => {
const before = `Uv="modulepreload",`;
const after = `,Wc={},`;
const jsCodeUntransformed = `${before}Hv=function(e){return"/foo/bar/baz/"+e}${after}`;
const { fixedJsCode } = replaceImportsInJsCode_vite({
"jsCode": jsCodeUntransformed,
"basenameOfAssetsFiles": [],
"buildOptions": {
"reactAppBuildDirPath": "/Users/someone/github/keycloakify-starter/dist/",
"assetsDirPath": "/Users/someone/github/keycloakify-starter/dist/assets/",
"urlPathname": "/foo/bar/baz/"
}
});
const fixedJsCodeExpected = `${before}Hv=function(e){return"/"+e}${after}`;
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
});
it("replaceImportsInJsCode_vite - 3", () => {
const jsCodeUntransformed = `
S="/assets/keycloakify-logo-mqjydaoZ.png",H=(()=>{
function __vite__mapDeps(indexes) {
if (!__vite__mapDeps.viteFileDeps) {
__vite__mapDeps.viteFileDeps = ["assets/Login-dJpPRzM4.js", "assets/index-XwzrZ5Gu.js"]
}
return indexes.map((i) => __vite__mapDeps.viteFileDeps[i])
}
`;
for (const { reactAppBuildDirPath, assetsDirPath, systemType } of [
{
"systemType": "posix",
"reactAppBuildDirPath": "/Users/someone/github/keycloakify-starter/dist",
"assetsDirPath": "/Users/someone/github/keycloakify-starter/dist/assets"
},
{
"systemType": "win32",
"reactAppBuildDirPath": "C:\\\\Users\\someone\\github\\keycloakify-starter\\dist",
"assetsDirPath": "C:\\\\Users\\someone\\github\\keycloakify-starter\\dist\\assets"
}
] as const) {
const { fixedJsCode } = replaceImportsInJsCode_vite({
"jsCode": jsCodeUntransformed,
"basenameOfAssetsFiles": ["Login-dJpPRzM4.js", "index-XwzrZ5Gu.js", "keycloakify-logo-mqjydaoZ.png"],
"buildOptions": {
reactAppBuildDirPath,
assetsDirPath,
"urlPathname": undefined
},
systemType
});
const fixedJsCodeExpected = `
S=(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/assets/keycloakify-logo-mqjydaoZ.png"),H=(()=>{
function __vite__mapDeps(indexes) {
if (!__vite__mapDeps.viteFileDeps) {
__vite__mapDeps.viteFileDeps = [
(window.${nameOfTheGlobal}.url.resourcesPath.substring(1) + "/${basenameOfTheKeycloakifyResourcesDir}/assets/Login-dJpPRzM4.js"),
(window.${nameOfTheGlobal}.url.resourcesPath.substring(1) + "/${basenameOfTheKeycloakifyResourcesDir}/assets/index-XwzrZ5Gu.js")
]
}
return indexes.map((i) => __vite__mapDeps.viteFileDeps[i])
}
`;
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
}
});
it("replaceImportsInJsCode_vite - 4", () => {
const jsCodeUntransformed = `
S="/foo/bar/keycloakify-logo-mqjydaoZ.png",H=(()=>{
function __vite__mapDeps(indexes) {
if (!__vite__mapDeps.viteFileDeps) {
__vite__mapDeps.viteFileDeps = ["foo/bar/Login-dJpPRzM4.js", "foo/bar/index-XwzrZ5Gu.js"]
}
return indexes.map((i) => __vite__mapDeps.viteFileDeps[i])
}
`;
for (const { reactAppBuildDirPath, assetsDirPath, systemType } of [
{
"systemType": "posix",
"reactAppBuildDirPath": "/Users/someone/github/keycloakify-starter/dist",
"assetsDirPath": "/Users/someone/github/keycloakify-starter/dist/foo/bar"
},
{
"systemType": "win32",
"reactAppBuildDirPath": "C:\\\\Users\\someone\\github\\keycloakify-starter\\dist",
"assetsDirPath": "C:\\\\Users\\someone\\github\\keycloakify-starter\\dist\\foo\\bar"
}
] as const) {
const { fixedJsCode } = replaceImportsInJsCode_vite({
"jsCode": jsCodeUntransformed,
"basenameOfAssetsFiles": ["Login-dJpPRzM4.js", "index-XwzrZ5Gu.js", "keycloakify-logo-mqjydaoZ.png"],
"buildOptions": {
reactAppBuildDirPath,
assetsDirPath,
"urlPathname": undefined
},
systemType
});
const fixedJsCodeExpected = `
S=(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/foo/bar/keycloakify-logo-mqjydaoZ.png"),H=(()=>{
function __vite__mapDeps(indexes) {
if (!__vite__mapDeps.viteFileDeps) {
__vite__mapDeps.viteFileDeps = [
(window.${nameOfTheGlobal}.url.resourcesPath.substring(1) + "/${basenameOfTheKeycloakifyResourcesDir}/foo/bar/Login-dJpPRzM4.js"),
(window.${nameOfTheGlobal}.url.resourcesPath.substring(1) + "/${basenameOfTheKeycloakifyResourcesDir}/foo/bar/index-XwzrZ5Gu.js")
]
}
return indexes.map((i) => __vite__mapDeps.viteFileDeps[i])
}
`;
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
}
});
it("replaceImportsInJsCode_vite - 5", () => {
const jsCodeUntransformed = `
S="/foo-bar-baz/assets/keycloakify-logo-mqjydaoZ.png",H=(()=>{
function __vite__mapDeps(indexes) {
if (!__vite__mapDeps.viteFileDeps) {
__vite__mapDeps.viteFileDeps = ["assets/Login-dJpPRzM4.js", "assets/index-XwzrZ5Gu.js"]
}
return indexes.map((i) => __vite__mapDeps.viteFileDeps[i])
}
`;
for (const { reactAppBuildDirPath, assetsDirPath, systemType } of [
{
"systemType": "posix",
"reactAppBuildDirPath": "/Users/someone/github/keycloakify-starter/dist",
"assetsDirPath": "/Users/someone/github/keycloakify-starter/dist/assets"
},
{
"systemType": "win32",
"reactAppBuildDirPath": "C:\\\\Users\\someone\\github\\keycloakify-starter\\dist",
"assetsDirPath": "C:\\\\Users\\someone\\github\\keycloakify-starter\\dist\\assets"
}
] as const) {
const { fixedJsCode } = replaceImportsInJsCode_vite({
"jsCode": jsCodeUntransformed,
"basenameOfAssetsFiles": ["Login-dJpPRzM4.js", "index-XwzrZ5Gu.js", "keycloakify-logo-mqjydaoZ.png"],
"buildOptions": {
reactAppBuildDirPath,
assetsDirPath,
"urlPathname": "/foo-bar-baz/"
},
systemType
});
const fixedJsCodeExpected = `
S=(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/assets/keycloakify-logo-mqjydaoZ.png"),H=(()=>{
function __vite__mapDeps(indexes) {
if (!__vite__mapDeps.viteFileDeps) {
__vite__mapDeps.viteFileDeps = [
(window.${nameOfTheGlobal}.url.resourcesPath.substring(1) + "/${basenameOfTheKeycloakifyResourcesDir}/assets/Login-dJpPRzM4.js"),
(window.${nameOfTheGlobal}.url.resourcesPath.substring(1) + "/${basenameOfTheKeycloakifyResourcesDir}/assets/index-XwzrZ5Gu.js")
]
}
return indexes.map((i) => __vite__mapDeps.viteFileDeps[i])
}
`;
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
}
});
});
describe("js replacer - webpack", () => {
it("replaceImportsInJsCode_webpack - 1", () => {
const jsCodeUntransformed = `
function f() {
return a.p+"static/js/" + ({}[e] || e) + "." + {
3: "0664cdc0"
}[e] + ".chunk.js"
}
function sameAsF() {
return a.p+"static/js/" + ({}[e] || e) + "." + {
3: "0664cdc0"
}[e] + ".chunk.js"
}
__webpack_require__.u=function(e){return"static/js/" + e + "." + {
147: "6c5cee76",
787: "8da10fcf",
922: "be170a73"
} [e] + ".chunk.js"
}
t.miniCssF=function(e){return"static/css/"+e+"."+{
164:"dcfd7749",
908:"67c9ed2c"
}[e]+".chunk.css"
}
n.u=e=>"static/js/"+e+"."+{69:"4f205f87",128:"49264537",453:"b2fed72e",482:"f0106901"}[e]+".chunk.js"
t.miniCssF=e=>"static/css/"+e+"."+{164:"dcfd7749",908:"67c9ed2c"}[e]+".chunk.css"
`;
const { fixedJsCode } = replaceImportsInJsCode_webpack({
"jsCode": jsCodeUntransformed,
"buildOptions": {
"reactAppBuildDirPath": "/Users/someone/github/keycloakify-starter/build",
"assetsDirPath": "/Users/someone/github/keycloakify-starter/build/static",
"urlPathname": undefined
}
});
const fixedJsCodeExpected = `
function f() {
return window.kcContext.url.resourcesPath + "/build/static/js/" + ({}[e] || e) + "." + {
3: "0664cdc0"
}[e] + ".chunk.js"
}
function sameAsF() {
return window.kcContext.url.resourcesPath + "/build/static/js/" + ({}[e] || e) + "." + {
3: "0664cdc0"
}[e] + ".chunk.js"
}
__webpack_require__[(function (){
var pd = Object.getOwnPropertyDescriptor(__webpack_require__, "p");
if( pd === undefined || pd.configurable ){
Object.defineProperty(__webpack_require__, "p", {
get: function() { return window.kcContext.url.resourcesPath; },
set: function() {}
});
}
return "u";
})()] = function(e) {
return "/build/static/js/" + e + "." + {
147: "6c5cee76",
787: "8da10fcf",
922: "be170a73"
} [e] + ".chunk.js"
}
t[(function (){
var pd = Object.getOwnPropertyDescriptor(t, "p");
if( pd === undefined || pd.configurable ){
Object.defineProperty(t, "p", {
get: function() { return window.kcContext.url.resourcesPath; },
set: function() {}
});
}
return "miniCssF";
})()] = function(e) {
return "/build/static/css/" + e + "." + {
164:"dcfd7749",
908:"67c9ed2c"
} [e] + ".chunk.css"
}
n[(function(){
var pd = Object.getOwnPropertyDescriptor(n, "p");
if( pd === undefined || pd.configurable ){
Object.defineProperty(n, "p", {
get: function() { return window.kcContext.url.resourcesPath; },
set: function() {}
});
}
return "u";
})()] = e => "/build/static/js/"+e+"."+{69:"4f205f87",128:"49264537",453:"b2fed72e",482:"f0106901"}[e]+".chunk.js"
t[(function(){
var pd = Object.getOwnPropertyDescriptor(t, "p");
if( pd === undefined || pd.configurable ){
Object.defineProperty(t, "p", {
get: function() { return window.kcContext.url.resourcesPath; },
set: function() {}
});
}
return "miniCssF";
})()] = e => "/build/static/css/"+e+"."+{164:"dcfd7749",908:"67c9ed2c"}[e]+".chunk.css"
`;
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
});
it("replaceImportsInJsCode_webpack - 2", () => {
const before = `"__esModule",{value:!0})}`;
const after = `function(){if("undefined"`;
const jsCodeUntransformed = `${before},n.p="/foo-bar/",${after}`;
const { fixedJsCode } = replaceImportsInJsCode_webpack({
"jsCode": jsCodeUntransformed,
"buildOptions": {
"reactAppBuildDirPath": "/Users/someone/github/keycloakify-starter/build",
"assetsDirPath": "/Users/someone/github/keycloakify-starter/build/static",
"urlPathname": "/foo-bar/"
}
});
const fixedJsCodeExpected = `${before},n.p="/",${after}`;
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
});
it("replaceImportsInJsCode_webpack - 3", () => {
const before = `"__esModule",{value:!0})}`;
const after = `function(){if("undefined"`;
const jsCodeUntransformed = `${before},n.p="/foo/bar/",${after}`;
const { fixedJsCode } = replaceImportsInJsCode_webpack({
"jsCode": jsCodeUntransformed,
"buildOptions": {
"reactAppBuildDirPath": "/Users/someone/github/keycloakify-starter/build",
"assetsDirPath": "/Users/someone/github/keycloakify-starter/dist/build/static",
"urlPathname": "/foo/bar/"
}
});
const fixedJsCodeExpected = `${before},n.p="/",${after}`;
expect(isSameCode(fixedJsCode, fixedJsCodeExpected)).toBe(true);
});
});
describe("css replacer", () => {
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) repeat center center;
}
.my-div {
background-image: url(/static/media/something.svg);
}
`
});
const fixedCssCodeExpected = `
.my-div {
background: var(--urla882a969fd39473) no-repeat center center;
}
.my-div2 {
background: var(--urla882a969fd39473) repeat center center;
}
.my-div {
background-image: var(--urldd75cab58377c19);
}
`;
expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
const cssGlobalsToDefineExpected = {
"urla882a969fd39473": "url(/logo192.png)",
"urldd75cab58377c19": "url(/static/media/something.svg)"
};
expect(same(cssGlobalsToDefine, cssGlobalsToDefineExpected)).toBe(true);
const { cssCodeToPrependInHead } = generateCssCodeToDefineGlobals({
cssGlobalsToDefine,
"buildOptions": {
"urlPathname": undefined
}
});
const cssCodeToPrependInHeadExpected = `
:root {
--urla882a969fd39473: url(\${url.resourcesPath}/build/logo192.png);
--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(--url749a3139386b2c8) no-repeat center center;
}
.my-div2 {
background: var(--url749a3139386b2c8) no-repeat center center;
}
.my-div {
background-image: var(--url8bdc0887b97ac9a);
}
`;
expect(isSameCode(fixedCssCode, fixedCssCodeExpected)).toBe(true);
const cssGlobalsToDefineExpected = {
"url749a3139386b2c8": "url(/x/y/z/logo192.png)",
"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 {
--url749a3139386b2c8: url(\${url.resourcesPath}/build/logo192.png);
--url8bdc0887b97ac9a: url(\${url.resourcesPath}/build/static/media/something.svg);
}
`;
expect(isSameCode(cssCodeToPrependInHead, cssCodeToPrependInHeadExpected)).toBe(true);
});
});
describe("inline css replacer", () => {
describe("no url pathName", () => {
const cssCode = `
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/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("/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("/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("/fonts/WorkSans/worksans-bold-webfont.woff2") format("woff2");
}
`;
it("transforms css for standalone app properly", () => {
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"urlPathname": undefined
}
});
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);
});
});
describe("with url pathName", () => {
const cssCode = `
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/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("/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("/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("/x/y/z/fonts/WorkSans/worksans-bold-webfont.woff2") format("woff2");
}
`;
it("transforms css for standalone app properly", () => {
const { fixedCssCode } = replaceImportsInInlineCssCode({
cssCode,
"buildOptions": {
"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);
});
});
});

View File

@ -24,7 +24,7 @@ vi.mock("keycloakify/bin/keycloakify/parsed-package-json", async () => ({
vi.mock("keycloakify/bin/promptKeycloakVersion", async () => ({
...((await vi.importActual("keycloakify/bin/promptKeycloakVersion")) as Record<string, unknown>),
promptKeycloakVersion: () => ({ keycloakVersion: "11.0.3" })
promptKeycloakVersion: () => ({ "keycloakVersion": "11.0.3" })
}));
const nativeCwd = process.cwd;
@ -52,19 +52,25 @@ describe("Sample Project", () => {
await setupSampleReactProject(sampleReactProjectDirPath);
await initializeEmailTheme();
const projectDirPath = process.cwd();
const reactAppRootDirPath = process.cwd();
const destDirPath = pathJoin(
readBuildOptions({
"processArgv": ["--silent"],
projectDirPath
reactAppRootDirPath
}).keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme"
);
await downloadBuiltinKeycloakTheme({ destDirPath, "keycloakVersion": "11.0.3", projectDirPath });
await downloadBuiltinKeycloakTheme({
destDirPath,
"keycloakVersion": "11.0.3",
"buildOptions": {
"cacheDirPath": pathJoin(reactAppRootDirPath, "node_modules", ".cache", "keycloakify")
}
});
},
{ timeout: 90000 }
);
@ -80,19 +86,25 @@ describe("Sample Project", () => {
await setupSampleReactProject(pathJoin(sampleReactProjectDirPath, "custom_input"));
await initializeEmailTheme();
const projectDirPath = process.cwd();
const reactAppRootDirPath = process.cwd();
const destDirPath = pathJoin(
readBuildOptions({
"processArgv": ["--silent"],
projectDirPath
reactAppRootDirPath
}).keycloakifyBuildDirPath,
"src",
"main",
"resources",
"theme"
);
await downloadBuiltinKeycloakTheme({ destDirPath, "keycloakVersion": "11.0.3", projectDirPath });
await downloadBuiltinKeycloakTheme({
destDirPath,
"keycloakVersion": "11.0.3",
buildOptions: {
"cacheDirPath": pathJoin(reactAppRootDirPath, "node_modules", ".cache", "keycloakify")
}
});
},
{ timeout: 90000 }
);

Some files were not shown because too many files have changed in this diff Show More