Compare commits

..

266 Commits

Author SHA1 Message Date
50590697ca Bump version 2024-10-30 15:51:32 +01:00
e261736fa3 Fix: kcContext.scripts can be undefined in error.ftl 2024-10-30 15:51:16 +01:00
263f55fdd3 Bump version 2024-10-26 22:07:54 +00:00
2b7f8a24a3 Fix import.meta.env.BASE_URL not being corectly replaced when build in windows 2024-10-26 22:07:29 +00:00
dc4eac1a04 Bump version 2024-10-25 04:12:20 +02:00
53a427d190 Delegate add sotry 2024-10-25 04:12:07 +02:00
ae969f91ac Bump version 2024-10-25 02:57:41 +02:00
c83319d6f3 Tell if we should update kcGen based on the hash 2024-10-25 02:57:26 +02:00
329b4cb0fb Enable to link in any keycloakify starter 2024-10-25 02:56:57 +02:00
533f5992d1 coding style fix 2024-10-25 02:44:19 +02:00
cb103cc3e2 Bump version 2024-10-25 00:21:15 +00:00
afdf89fb12 Try to run format on behaf of the user when generating new files with the CLI 2024-10-25 00:20:35 +00:00
26a87b8eaa Remove debug log 2024-10-25 00:01:39 +00:00
25d31463f4 Make script delegation work on windows 2024-10-25 00:01:12 +00:00
2542c38c9b Make linking script work on windows server 2024-10-24 23:47:34 +00:00
7326038424 Fix linking script on windows 2024-10-24 23:21:12 +00:00
12e632d221 More sensible patch for building on windows 2024-10-24 22:38:26 +00:00
3fc2108214 Fix build for windows 2024-10-24 16:56:13 +00:00
babbe39494 Merge pull request #701 from nima70/main
Removed Error Message from Terms and Conditions Page
2024-10-20 21:10:56 +02:00
32b4585e39 I removed the local=en 2024-10-20 13:49:10 -04:00
43469a869c I removed the error message for terms and conditions page 2024-10-20 12:35:14 -04:00
6f823e6478 Bump version 2024-10-20 13:08:59 +02:00
e33693e20e Merge pull request #700 from nima70/main
Storybooks Second Release: Login Page Stories with Improved Coverage
2024-10-20 13:02:27 +02:00
ad3f091d4a Merge pull request #698 from keycloakify/decouple_userprofile_logic
Decouple userprofile logic
2024-10-20 12:52:48 +02:00
3ff01d186d account page test coverage improved 2024-10-19 19:10:32 -04:00
0cf8caa53b storybooks second release 2024-10-19 17:24:29 -04:00
25920c208d Release candidate 2024-10-19 22:33:34 +02:00
19da96113f Don't export internals 2024-10-19 22:33:34 +02:00
6e584e809e Merge branch 'main' into decouple_userprofile_logic 2024-10-19 22:29:48 +02:00
4185188a5b Release candidate 2024-10-19 22:28:10 +02:00
4273322ed5 docs: update .all-contributorsrc [skip ci] 2024-10-19 22:27:30 +02:00
ba0532c95d docs: update README.md [skip ci] 2024-10-19 22:27:30 +02:00
3a2fe597ba Bump version 2024-10-19 22:27:30 +02:00
dda77952a0 #694 Probably some shell handle double quote differently 2024-10-19 22:27:30 +02:00
d2e518d96b #693 #692 2024-10-19 22:27:30 +02:00
f3a97b2538 Bump version 2024-10-19 22:27:29 +02:00
cacd017244 #696 2024-10-19 22:27:29 +02:00
f5b15a5ef6 Fix Phase two links 2024-10-19 22:27:29 +02:00
de620dca56 Fix light mode rendering 2024-10-19 22:27:29 +02:00
8decf4a3c9 Add phaseTwo as sponsor 2024-10-19 22:27:29 +02:00
831326952b Resize zone2 logo 2024-10-19 22:27:29 +02:00
27da578446 Bump version 2024-10-19 22:27:29 +02:00
2c1cca168f Resolve package.json path relative to the package.json 2024-10-19 22:27:29 +02:00
e498fb784b Bump version 2024-10-19 22:27:29 +02:00
2917719315 Add dir=rtl attribut to html when using a RTL language 2024-10-19 22:27:29 +02:00
9ed90995e4 typesafety fix 2024-10-19 22:27:29 +02:00
0f99bb5bdc fix: added parameter type for story context on register page 2024-10-19 22:27:29 +02:00
1f4d4473e4 Bump version 2024-10-19 22:27:29 +02:00
5332001ff4 docs: update .all-contributorsrc [skip ci] 2024-10-19 22:27:29 +02:00
22241fd7ad docs: update README.md [skip ci] 2024-10-19 22:27:29 +02:00
ddeade9775 Changes:
- First draft of test coverage improvement for storybooks
- code's page html rendering issue fixed
2024-10-19 22:27:29 +02:00
f1cb165bdd Bump version 2024-10-19 22:27:29 +02:00
9873353990 Fix initialize-email-theme 2024-10-19 22:27:29 +02:00
b879569b81 Announcement about Keycloak 26 2024-10-19 22:27:29 +02:00
c3e821088b Bump version 2024-10-19 22:27:29 +02:00
dc4f386e7a Fix vite quitting if custom handler implemented 2024-10-19 22:27:29 +02:00
a40810b364 Bump version 2024-10-19 22:27:29 +02:00
1690629717 Fix: check for delegation of the eject-page command 2024-10-19 22:27:29 +02:00
9a6a71c8bc Fix litle inconsistency 2024-10-19 22:27:29 +02:00
d626699f08 Bump version 2024-10-19 22:27:29 +02:00
6aa60e685b Release candidate 2024-10-19 22:27:29 +02:00
9910762abc Add initialize-email-theme, initialize-account-theme and copy-keycloak-resources-to-public to commands that can be delegated to a custom handler 2024-10-19 22:27:29 +02:00
182fb430f1 Fix dead code 2024-10-19 22:27:29 +02:00
bda20e2fbe Release candidate 2024-10-19 22:27:29 +02:00
bc586eceef Make sure the update-kc-gen command is delegated when building with vite 2024-10-19 22:27:29 +02:00
128b27221a Release candidate 2024-10-19 22:27:29 +02:00
2dfb4eda9d No need to handle non react environement with custom handler support 2024-10-19 22:27:29 +02:00
fed6af4dfe Release candidate 2024-10-19 22:27:29 +02:00
c4ee6cd85c Fix not handling correctly exit cause 2024-10-19 22:27:29 +02:00
8fc307bd8d Release candidate 2024-10-19 22:27:29 +02:00
9e9ffcd586 add debug logs 2024-10-19 22:27:29 +02:00
49b064b5f2 Release candidate 2024-10-19 22:27:29 +02:00
ef6f5a4c23 Add other missing declaration files 2024-10-19 22:27:28 +02:00
e92562fd44 Release candidate 2024-10-19 22:27:28 +02:00
fe65ddb5f8 Fix missing exports 2024-10-19 22:27:28 +02:00
ffd405c6db Release candidate 2024-10-19 22:27:28 +02:00
9e41868e0d Implement custom handler cli hook 2024-10-19 22:27:28 +02:00
ca6accc889 Bump version 2024-10-19 22:27:28 +02:00
dfe2e1562a Fix cache issue 2024-10-19 22:27:28 +02:00
ab43bb73d7 Bump version 2024-10-19 22:27:28 +02:00
22b0b95e54 Update readme, support keycloak 26 2024-10-19 22:27:28 +02:00
290ad8b592 Update version ranges for Multi-Page account theme 2024-10-19 22:27:28 +02:00
d5519dbb55 Release candidate 2024-10-19 22:27:28 +02:00
4de9e059e9 Aditional context exclusion 2024-10-19 22:27:28 +02:00
e573aff6ae Release candidate 2024-10-19 22:27:28 +02:00
908e083dee Update version target range 2024-10-19 22:27:28 +02:00
ec29724997 Fix link in CONTRIBUTING.md 2024-10-19 22:27:28 +02:00
88756e9807 Bump version 2024-10-19 22:27:28 +02:00
80d8a0c4e3 ['select-radiobuttons'/'multiselect-checkboxes'] fixed 'inputOptionLabels' 2024-10-19 22:27:28 +02:00
7241f0c741 Bump version 2024-10-19 22:27:28 +02:00
8565eb3fb8 Update tsafe 2024-10-19 22:27:28 +02:00
87198f6e56 docs: update .all-contributorsrc [skip ci] 2024-10-19 22:27:28 +02:00
fa934da442 docs: update README.md [skip ci] 2024-10-19 22:27:28 +02:00
6c4dc711d2 Put Kathi as first contributor 2024-10-19 22:27:28 +02:00
1f2a755a97 docs: update .all-contributorsrc [skip ci] 2024-10-19 22:27:28 +02:00
a0e3dc163a docs: update README.md [skip ci] 2024-10-19 22:27:28 +02:00
810dc6ceb5 Bump version 2024-10-19 22:27:28 +02:00
7203c742be Avoid modifying BASE_URL for App context 2024-10-19 22:27:28 +02:00
2fd04cfb61 Bump version 2024-10-19 22:27:28 +02:00
9c44d13f73 Update tsafe (provide ESM distribution) 2024-10-19 22:27:27 +02:00
d6436a58a2 update ci 2024-10-19 22:27:27 +02:00
613167f3a6 Bump version 2024-10-19 22:27:27 +02:00
ab0c281d98 Fix allegated vulnerability 2024-10-19 22:27:27 +02:00
c84dc281a2 Bump version 2024-10-19 22:27:27 +02:00
835833a61b Remove unessesary reference to react specific construct in KcContext 2024-10-19 22:27:27 +02:00
9af542ec89 Bump version 2024-10-19 22:27:27 +02:00
06e33196bb Refactor: Make ClassKey importable without having react as a dependency 2024-10-19 22:27:27 +02:00
36dd324139 complete decoupling of user profile form validation logic 2024-10-19 10:18:22 +02:00
52d4fe920c Merge pull request #697 from keycloakify/all-contributors/add-marvinruder
docs: add marvinruder as a contributor for bug
2024-10-19 03:21:05 +02:00
0d090d50d4 Bump version 2024-10-19 02:28:32 +02:00
e57232edde #694 Probably some shell handle double quote differently 2024-10-19 02:28:11 +02:00
dfe1e7ddd1 docs: update .all-contributorsrc [skip ci] 2024-10-19 00:24:37 +00:00
5ffc42c9db docs: update README.md [skip ci] 2024-10-19 00:24:36 +00:00
c63648a1b0 #693 #692 2024-10-19 02:19:41 +02:00
80fd4095c4 Bump version 2024-10-17 23:23:52 +02:00
31d7a938f2 #696 2024-10-17 23:23:26 +02:00
ee1b6868f8 Fix Phase two links 2024-10-17 19:54:14 +02:00
7c7e5544e4 Fix light mode rendering 2024-10-16 05:13:52 +02:00
06fe26fbe7 Add phaseTwo as sponsor 2024-10-16 04:10:17 +02:00
c932c7d8f6 Resize zone2 logo 2024-10-16 03:37:00 +02:00
d56c536446 Bump version 2024-10-13 00:55:23 +02:00
f5a9a28124 Resolve package.json path relative to the package.json 2024-10-13 00:55:06 +02:00
b86039536e Bump version 2024-10-12 17:33:44 +02:00
59c4675e8a Add dir=rtl attribut to html when using a RTL language 2024-10-12 17:30:30 +02:00
fbf6a329df typesafety fix 2024-10-11 23:55:07 +02:00
ddec3118a4 Merge pull request #688 from keycloakify/fix/missing-type-register-story
fix: added parameter type for story context on register page
2024-10-08 23:58:06 +02:00
94e2786297 fix: added parameter type for story context on register page 2024-10-08 16:50:11 -05:00
ecfdff5454 Bump version 2024-10-07 23:53:05 +02:00
c598a58ec9 Merge pull request #685 from keycloakify/all-contributors/add-nima70
docs: add nima70 as a contributor for code, and test
2024-10-07 23:47:25 +02:00
3e38beb190 docs: update .all-contributorsrc [skip ci] 2024-10-07 21:47:11 +00:00
61a86e8e82 docs: update README.md [skip ci] 2024-10-07 21:47:10 +00:00
9c8e127fa0 Merge pull request #672 from nima70/main
Changes:
2024-10-07 23:46:13 +02:00
eb23b40e5c Bump version 2024-10-07 21:03:04 +02:00
9e19aafcd0 Fix initialize-email-theme 2024-10-07 21:02:51 +02:00
4cdf26b6f9 Announcement about Keycloak 26 2024-10-07 20:56:03 +02:00
88de58cc22 Bump version 2024-10-06 22:56:42 +02:00
f6b48c88b9 Fix vite quitting if custom handler implemented 2024-10-06 22:55:18 +02:00
12534e57ad Bump version 2024-10-06 22:09:21 +02:00
52e33bba2d Fix: check for delegation of the eject-page command 2024-10-06 22:08:43 +02:00
d63e5f4e54 Fix litle inconsistency 2024-10-06 15:37:32 +02:00
8d31866a0b Bump version 2024-10-06 15:09:53 +02:00
7d818f217a Merge pull request #683 from keycloakify/feat_custom_handler
Feat custom handler
2024-10-06 15:09:26 +02:00
7156665684 Release candidate 2024-10-06 13:19:12 +02:00
5045c5e8bf Add initialize-email-theme, initialize-account-theme and copy-keycloak-resources-to-public to commands that can be delegated to a custom handler 2024-10-06 13:18:30 +02:00
9de2ed9eaf Fix dead code 2024-10-06 12:44:46 +02:00
096cf7a570 Release candidate 2024-10-06 09:07:10 +02:00
a04f07d149 Make sure the update-kc-gen command is delegated when building with vite 2024-10-06 09:06:49 +02:00
63775b2866 Release candidate 2024-10-06 06:45:06 +02:00
e8609de7b4 No need to handle non react environement with custom handler support 2024-10-06 06:44:53 +02:00
e62aa89d72 Release candidate 2024-10-06 06:42:04 +02:00
77f12a940d Fix not handling correctly exit cause 2024-10-06 06:41:51 +02:00
0fe49e3d6e Release candidate 2024-10-05 22:29:13 +02:00
881386a123 add debug logs 2024-10-05 22:28:36 +02:00
7b9aec4ed0 Release candidate 2024-10-05 21:39:32 +02:00
cf18f9d06c Add other missing declaration files 2024-10-05 21:39:14 +02:00
052936f769 Release candidate 2024-10-05 21:23:57 +02:00
590de7a67b Fix missing exports 2024-10-05 21:23:17 +02:00
7f608ad8ad Release candidate 2024-10-05 20:31:41 +02:00
35b012b937 Implement custom handler cli hook 2024-10-05 20:30:09 +02:00
e3bd7f3bc5 Bump version 2024-10-04 16:56:17 +02:00
e14f187fc0 Fix cache issue 2024-10-04 16:56:02 +02:00
da495b90ae Bump version 2024-10-04 13:00:15 +02:00
8d9b80f549 Update readme, support keycloak 26 2024-10-04 12:59:56 +02:00
2e9da33622 Merge pull request #681 from keycloakify/keycloak-26
Update version target range
2024-10-04 12:58:50 +02:00
6f416ad335 Update version ranges for Multi-Page account theme 2024-10-04 12:58:31 +02:00
4e982ee898 Release candidate 2024-10-04 12:44:22 +02:00
bcb514ae9c Aditional context exclusion 2024-10-04 12:44:03 +02:00
cfdad8d71d Release candidate 2024-10-04 12:17:54 +02:00
39ad1eb8d1 Update version target range 2024-10-04 12:17:08 +02:00
3d1d2e316b Merge pull request #680 from pnzrr/pnzrr-patch-1
Fix link in CONTRIBUTING.md
2024-10-04 06:58:52 +02:00
dd217e8a46 Fix link in CONTRIBUTING.md 2024-10-03 21:04:02 -06:00
1339a96ea4 Bump version 2024-10-02 23:36:58 +02:00
616e834c90 Merge pull request #678 from johanjk/main
respect inputOptionLabels
2024-10-02 23:36:23 +02:00
80eaa77acc ['select-radiobuttons'/'multiselect-checkboxes'] fixed 'inputOptionLabels' 2024-10-02 16:16:16 +02:00
ce3135c83b Bump version 2024-10-02 13:44:22 +02:00
09abc73068 Update tsafe 2024-10-02 13:42:38 +02:00
037d623550 Merge pull request #676 from keycloakify/all-contributors/add-luca-peruzzo
docs: add luca-peruzzo as a contributor for code, and test
2024-10-02 11:05:58 +02:00
8c8d2fd6a8 docs: update .all-contributorsrc [skip ci] 2024-10-02 09:05:35 +00:00
153a99d63f docs: update README.md [skip ci] 2024-10-02 09:05:34 +00:00
939e3ca7ea Put Kathi as first contributor 2024-10-02 11:02:25 +02:00
a0dc7eeb7c Merge pull request #675 from keycloakify/all-contributors/add-kathari00
docs: add kathari00 as a contributor for code, test, and doc
2024-10-02 11:00:06 +02:00
c21d072231 docs: update .all-contributorsrc [skip ci] 2024-10-02 08:59:49 +00:00
2e10ec8073 docs: update README.md [skip ci] 2024-10-02 08:59:48 +00:00
1177d6770c Bump version 2024-10-01 11:59:39 +02:00
d492a393fe Merge pull request #674 from keycloakify/dont_touch_base_url
Avoid modifying BASE_URL for App context
2024-10-01 11:59:14 +02:00
77952337c5 Avoid modifying BASE_URL for App context 2024-10-01 11:52:40 +02:00
6716fcb881 Bump version 2024-09-30 18:10:26 +02:00
302fe8d7cd Update tsafe (provide ESM distribution) 2024-09-30 18:10:09 +02:00
2ea5e34e81 update ci 2024-09-30 17:57:41 +02:00
d7103b1ad9 Bump version 2024-09-30 11:49:33 +02:00
9f8a36fe93 Fix allegated vulnerability 2024-09-30 11:48:57 +02:00
47ca811878 Bump version 2024-09-30 01:22:49 +02:00
8cacb21f1b Remove unessesary reference to react specific construct in KcContext 2024-09-30 01:22:37 +02:00
a0c95207cf Bump version 2024-09-30 01:10:45 +02:00
da3023cf5e Refactor: Make ClassKey importable without having react as a dependency 2024-09-30 00:31:27 +02:00
5892cf2ba7 Decouple user profile form logic so it can be consumed in angular 2024-09-30 00:19:37 +02:00
c9d7fc1b6e Changes:
- First draft of test coverage improvement for storybooks
- code's page html rendering issue fixed
2024-09-29 04:35:02 -04:00
94779c3476 Bump version 2024-09-28 00:43:55 +02:00
802a6ab5ec Explicitely prohibit space and special character in theme names 2024-09-28 00:34:24 +02:00
04307c8226 Remove dead code 2024-09-28 00:17:17 +02:00
ff6b91b801 refactor 2024-09-28 00:05:19 +02:00
c8ca598465 Refactor 2024-09-27 23:45:14 +02:00
9444b897ee #669 2024-09-27 23:37:23 +02:00
3d1951b72c Merge together generateResourcesForMainTheme and generateResourcesForThemeVariant 2024-09-27 23:05:51 +02:00
acc27ae448 #668 2024-09-26 20:34:00 +02:00
e6993214ff Bump version 2024-09-25 10:26:46 +02:00
2f02a4379c Enable i18n in Single-Page account theme 2024-09-25 10:26:25 +02:00
b57d014e9a Bump version 2024-09-24 19:47:01 +02:00
f57f311aab Fix async io not awaited and don't crash if .ftl files does not exist for some reason 2024-09-24 19:47:01 +02:00
4f11415107 Merge pull request #667 from keycloakify/all-contributors/add-uchar
docs: add uchar as a contributor for test, and code
2024-09-23 05:00:23 +02:00
346fd7175f Only publish storybook if we are on main 2024-09-23 04:36:00 +02:00
7c02d77057 docs: update .all-contributorsrc [skip ci] 2024-09-23 02:31:26 +00:00
d3fd4b6bbf docs: update README.md [skip ci] 2024-09-23 02:31:25 +00:00
43ef527810 Bump version 2024-09-23 00:29:17 +02:00
a6032a1387 Merge pull request #653 from keycloakify/i18n_extraLanguages_and_perThemeVariantTranslations
Start implementing per theme variant translations and ability to add extra languages
2024-09-23 00:23:55 +02:00
23179cac53 Fix last bug 2024-09-23 00:19:34 +02:00
954c3319bb Fix bug in label resolution 2024-09-22 23:46:45 +02:00
eb6ec0275d Remove debug log 2024-09-22 22:53:31 +02:00
890f8bc2d5 Fix: Forget to create a dir before writing files 2024-09-22 22:53:13 +02:00
26b8dd9cda Improve intentionality 2024-09-22 22:48:31 +02:00
c07af8491c Complete statical parsing of withExtraLanguages 2024-09-22 22:46:56 +02:00
10d4da9fbf No need to escape since we sanitize 2024-09-22 22:18:24 +02:00
95e861099f Integrate kcSanitize 2024-09-22 20:41:18 +02:00
6dc51dfab3 Fix some bugs in the vendoring script 2024-09-22 20:21:07 +02:00
ddb0af1dcb Vendor dompurify, use isomorphic-dompurify only for tests 2024-09-22 20:12:11 +02:00
b6e9043d91 Reorganize kcSanitarize 2024-09-22 18:56:05 +02:00
7c553ee10d Restore package.json and yarn.lock 2024-09-22 18:29:29 +02:00
2a6b14adc6 Merge pull request #666 from uchar/fix/dangerouslySetInnerHTML
Fix/dangerously set inner html
2024-09-22 18:27:25 +02:00
159a5f60d0 Add missing scope in ftl template 2024-09-22 18:22:11 +02:00
08f03b3118 Merge branch 'main' into i18n_extraLanguages_and_perThemeVariantTranslations 2024-09-22 18:15:25 +02:00
f137960f96 Reneame useStylesAndScript to useInitialize 2024-09-22 18:12:46 +02:00
e5ab46727a Make the i18n API more type safe 2024-09-22 17:14:03 +02:00
8d2679b76e Progess in parsing of the extra languages provided by the user 2024-09-22 15:39:32 +02:00
b0b6b994ed Almost done, left to extract the extra language resources 2024-09-22 04:39:24 +02:00
bb163132fe Fix minor inconsistency 2024-09-21 23:42:59 +02:00
439bed2f24 Update account theme i18n.ts boilerplate 2024-09-21 23:41:08 +02:00
5a233d8878 Avoid too many types declaration indirections 2024-09-21 23:35:44 +02:00
20cdbb6185 Rename .create() by .build() for i18nBuilder 2024-09-21 23:21:15 +02:00
b3c4208e44 Rename i18nInitializer by i18nBuilder 2024-09-21 23:09:12 +02:00
8623037224 Various little adjustments relative to the new i18n API 2024-09-21 22:35:30 +02:00
e8d3d3d741 Automatically generate account i18n code 2024-09-21 21:44:14 +02:00
cc700f0ba0 Untrack account i18n, code will be generated automatically 2024-09-21 21:33:57 +02:00
801a5cce17 Enable to add label to extra message not in the default set 2024-09-21 21:33:04 +02:00
2a3ad58c18 Throw an error if providing translation for a language that is already supported 2024-09-21 18:17:43 +02:00
969744f4cb Complete runtime API implementation 2024-09-21 17:59:16 +02:00
40ebbdebeb Fix some type errors 2024-09-21 04:45:00 +02:00
eb64886dcf Generate LanguageTage.ts 2024-09-21 04:36:48 +02:00
81fc9d57bd remove async from sanitize 2024-09-18 18:37:17 +03:30
66b480f837 use textarea on client for decode 2024-09-18 11:13:49 +03:30
7e6a84ce19 Add more tests 2024-09-17 09:39:07 +03:30
68e7642827 Remove extra comment 2024-09-17 01:11:14 +03:30
b37c7ccc8a Merge with master 2024-09-17 01:01:45 +03:30
b7c9ba8ffd Merge branch 'main' of https://github.com/uchar/keycloakify into fix/dangerouslySetInnerHTML 2024-09-17 01:01:02 +03:30
c8a31c4b6a Add KCSantisizer 2024-09-17 00:56:46 +03:30
fb6f450bfe Bump version 2024-09-16 14:30:50 +02:00
9a97d86ff9 #638 #631 Follow up 2024-09-16 14:30:27 +02:00
a5e3ecb38b Bump version 2024-09-16 13:50:08 +02:00
9b22d94600 Remove previous .keycloakify dir 2024-09-16 13:49:54 +02:00
a42ddb959b Bump version 2024-09-16 13:38:05 +02:00
b4e94d3c00 Use keycloakify-dev-resources instead of .keycloakify 2024-09-16 13:37:29 +02:00
aad89a2001 Start implementing per theme variant translations and ability to add extra languages 2024-09-15 16:55:18 +02:00
07e4f99f80 Bump version 2024-09-13 12:47:01 +02:00
715562c750 Merge pull request #646 from BII-GmbH/main 2024-09-13 12:46:16 +02:00
ef4f4d8374 allow docker start script to work with podman 2024-09-13 10:58:52 +02:00
e15f13646c GitHub pages does not serve dotfile, patch storybook build #645 2024-09-10 19:31:03 +02:00
8677c17f29 #645 2024-09-10 19:01:20 +02:00
151 changed files with 12117 additions and 3501 deletions

View File

@ -259,6 +259,56 @@
"code",
"doc"
]
},
{
"login": "uchar",
"name": "Omid",
"avatar_url": "https://avatars.githubusercontent.com/u/5172296?v=4",
"profile": "https://www.linkedin.com/in/oes-rioniz/",
"contributions": [
"test",
"code"
]
},
{
"login": "kathari00",
"name": "Katharina Eiserfey",
"avatar_url": "https://avatars.githubusercontent.com/u/42547712?v=4",
"profile": "https://github.com/kathari00",
"contributions": [
"code",
"test",
"doc"
]
},
{
"login": "luca-peruzzo",
"name": "Luca Peruzzo",
"avatar_url": "https://avatars.githubusercontent.com/u/69015314?v=4",
"profile": "https://github.com/luca-peruzzo",
"contributions": [
"code",
"test"
]
},
{
"login": "nima70",
"name": "Nima Shokouhfar",
"avatar_url": "https://avatars.githubusercontent.com/u/5094767?v=4",
"profile": "https://github.com/nima70",
"contributions": [
"code",
"test"
]
},
{
"login": "marvinruder",
"name": "Marvin A. Ruder",
"avatar_url": "https://avatars.githubusercontent.com/u/18495294?v=4",
"profile": "https://mruder.dev",
"contributions": [
"bug"
]
}
],
"contributorsPerLine": 7,

View File

@ -37,7 +37,7 @@ jobs:
storybook:
runs-on: ubuntu-latest
if: github.event_name == 'push'
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
steps:
- uses: actions/checkout@v4
@ -49,7 +49,7 @@ jobs:
- run: git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${{github.repository}}.git
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npx -y -p gh-pages@3.1.0 gh-pages -d ./storybook-static -u "github-actions-bot <actions@github.com>"
- run: npx -y -p gh-pages@3.1.0 gh-pages -d ./storybook-static -u "github-actions-bot <actions@github.com>"
check_if_version_upgraded:
name: Check if version upgrade
@ -112,7 +112,7 @@ jobs:
registry-url: https://registry.npmjs.org/
- uses: bahmutov/npm-install@v1
- run: npm run build
- run: npx -y -p denoify@1.6.12 enable_short_npm_import_path
- run: npx -y -p denoify@1.6.13 enable_short_npm_import_path
env:
DRY_RUN: "0"
- uses: garronej/ts-ci@v2.1.2

2
.gitignore vendored
View File

@ -49,7 +49,7 @@ jspm_packages
.idea
/src/login/i18n/messages_defaultSet/
/src/account/i18n/messages_defaultSet/
/src/account/i18n/
# VS Code devcontainers
.devcontainer

View File

@ -1,3 +1,3 @@
Looking to contribute? Thank you! PR are more than welcome.
Please refers to [this documentation page](https://docs.keycloakify.dev/contributing) that will help you get started.
Please refers to [this documentation page](https://docs.keycloakify.dev/faq-and-help/contributing) that will help you get started.

View File

@ -41,23 +41,50 @@
<img width="400" src="https://github.com/user-attachments/assets/6bf3bef9-00b0-4460-97b9-0d2da8500798">
</p>
Keycloakify is fully compatible with Keycloak from version 11 to 25...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
Keycloakify is fully compatible with Keycloak from version 11 to 26...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
> 📣 **Keycloakify 26 Released**
> Themes built with Keycloakify versions **prior** to Keycloak 26 are **incompatible** with Keycloak 26.
> To ensure compatibility, simply upgrade to the latest Keycloakify version for your major release (v10 or v11) and rebuild your theme.
> No breaking changes have been introduced, but the target version ranges have been updated. For more details, see [this guide](https://docs.keycloakify.dev/targeting-specific-keycloak-versions).
## Sponsors
Friends for the project, we trust and recommend their services.
Project backers, we trust and recommend their services.
<br/>
<div align="center">
![Logo Dark](https://github.com/user-attachments/assets/088f6631-b7ef-42ad-812b-df4870dc16ae#gh-dark-mode-only)
![Logo Dark](https://github.com/user-attachments/assets/d8f6b6f5-3de4-4adc-ba15-cb4074e8309b#gh-dark-mode-only)
</div>
<div align="center">
![Logo Light](https://github.com/user-attachments/assets/53fb16f8-02ef-4523-9c36-b42d6e59837e#gh-light-mode-only)
![Logo Light](https://github.com/user-attachments/assets/20736d6f-f22d-4a9d-9dfe-93be209a8191#gh-light-mode-only)
</div>
<br/>
<p align="center">
<i><a href="https://phasetwo.io/?utm_source=keycloakify"><strong>Keycloak as a Service</strong></a> - Keycloak community contributors of popular <a href="https://github.com/p2-inc#our-extensions-?utm_source=keycloakify">extensions</a> providing free and dedicated <a href="https://phasetwo.io/hosting/?utm_source=keycloakify">Keycloak hosting</a> and enterprise <a href="https://phasetwo.io/support/?utm_source=keycloakify">Keycloak support</a> to businesses of all sizes.</i>
</p>
<br/>
<br/>
<br/>
<div align="center">
![Logo Dark](https://github.com/user-attachments/assets/dd3925fb-a58a-4e91-b360-69c2fa1f1087#gh-dark-mode-only)
</div>
<div align="center">
![Logo Light](https://github.com/user-attachments/assets/6c00c201-eed7-485a-a887-70891559d69b#gh-light-mode-only)
</div>
@ -82,7 +109,7 @@ Friends for the project, we trust and recommend their services.
</div>
<p align="center">
<a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github"><strong>Managed Keycloak Provider</strong> - With Cloud-IAM powering your Keycloak clusters, you can sleep easy knowing you've got the software and the experts you need for operational excellence. </a>
<a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github"><strong>Managed Keycloak Provider</strong> - With Cloud-IAM powering your Keycloak clusters, you can sleep easy knowing you've got the software and the experts you need for operational excellence. Cloud IAM is a french company. </a>
<br/>
Use code <code>keycloakify5</code> at checkout for a 5% discount.
</p>
@ -132,6 +159,13 @@ 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/oliviergoulet5"><img src="https://avatars.githubusercontent.com/u/17685861?v=4?s=100" width="100px;" alt="Olivier Goulet"/><br /><sub><b>Olivier Goulet</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=oliviergoulet5" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/liamlows"><img src="https://avatars.githubusercontent.com/u/1365914?v=4?s=100" width="100px;" alt="Liam Lowsley-Williams"/><br /><sub><b>Liam Lowsley-Williams</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=liamlows" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=liamlows" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://www.linkedin.com/in/oes-rioniz/"><img src="https://avatars.githubusercontent.com/u/5172296?v=4?s=100" width="100px;" alt="Omid"/><br /><sub><b>Omid</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=uchar" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=uchar" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kathari00"><img src="https://avatars.githubusercontent.com/u/42547712?v=4?s=100" width="100px;" alt="Katharina Eiserfey"/><br /><sub><b>Katharina Eiserfey</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kathari00" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=kathari00" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=kathari00" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/luca-peruzzo"><img src="https://avatars.githubusercontent.com/u/69015314?v=4?s=100" width="100px;" alt="Luca Peruzzo"/><br /><sub><b>Luca Peruzzo</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=luca-peruzzo" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=luca-peruzzo" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/nima70"><img src="https://avatars.githubusercontent.com/u/5094767?v=4?s=100" width="100px;" alt="Nima Shokouhfar"/><br /><sub><b>Nima Shokouhfar</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=nima70" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=nima70" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://mruder.dev"><img src="https://avatars.githubusercontent.com/u/18495294?v=4?s=100" width="100px;" alt="Marvin A. Ruder"/><br /><sub><b>Marvin A. Ruder</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/issues?q=author%3Amarvinruder" title="Bug reports">🐛</a></td>
</tr>
</tbody>
</table>

View File

@ -1,7 +1,7 @@
{
"name": "keycloakify",
"version": "10.1.0",
"description": "Create Keycloak themes using React",
"version": "11.3.14",
"description": "Framework to create custom Keycloak UIs",
"repository": {
"type": "git",
"url": "git://github.com/keycloakify/keycloakify.git"
@ -38,12 +38,14 @@
"dist/",
"!dist/tsconfig.tsbuildinfo",
"!dist/bin/",
"dist/bin/**/*.d.ts",
"dist/bin/main.js",
"dist/bin/*.index.js",
"dist/bin/*.node",
"dist/bin/shared/constants.js",
"dist/bin/shared/*.d.ts",
"dist/bin/shared/*.js.map",
"dist/bin/shared/constants.js.map",
"dist/bin/shared/customHandler.js",
"dist/bin/shared/customHandler.js.map",
"!dist/vite-plugin/",
"dist/vite-plugin/index.js",
"dist/vite-plugin/index.d.ts",
@ -62,12 +64,13 @@
],
"homepage": "https://www.keycloakify.dev",
"dependencies": {
"tsafe": "^1.6.6"
"tsafe": "^1.7.5"
},
"devDependencies": {
"@babel/core": "^7.24.5",
"@babel/generator": "^7.24.5",
"@babel/parser": "^7.24.5",
"@babel/preset-env": "7.24.8",
"@babel/types": "^7.24.5",
"@emotion/react": "^11.11.4",
"@octokit/rest": "^20.1.1",
@ -75,20 +78,27 @@
"@storybook/builder-webpack5": "^6.5.13",
"@storybook/manager-webpack5": "^6.5.13",
"@storybook/react": "^6.5.13",
"eslint-plugin-storybook": "^0.6.7",
"@types/babel__generator": "^7.6.4",
"@types/dompurify": "^2.0.0",
"@types/make-fetch-happen": "^10.0.1",
"@types/minimist": "^1.2.2",
"@types/node": "^18.15.3",
"@types/properties-parser": "^0.3.3",
"@types/react": "^18.0.35",
"@types/react-dom": "^18.0.11",
"@types/yauzl": "^2.10.3",
"@vercel/ncc": "^0.38.1",
"babel-loader": "9.1.3",
"chalk": "^4.1.2",
"cheerio": "1.0.0-rc.12",
"chokidar-cli": "^3.0.0",
"cli-select": "^1.1.2",
"dompurify": "^3.1.6",
"eslint-plugin-storybook": "^0.6.7",
"evt": "^2.5.7",
"html-entities": "^2.5.2",
"husky": "^4.3.8",
"isomorphic-dompurify": "^2.15.0",
"lint-staged": "^11.0.0",
"magic-string": "^0.30.7",
"make-fetch-happen": "^11.0.3",
@ -103,12 +113,13 @@
"termost": "^v0.12.1",
"tsc-alias": "^1.8.10",
"tss-react": "^4.9.10",
"tsx": "^4.15.5",
"typescript": "^4.9.4",
"vite": "^5.2.11",
"vitest": "^1.6.0",
"webpack": "5.93.0",
"webpack-cli": "5.1.4",
"yauzl": "^2.10.0",
"zod": "^3.17.10",
"evt": "^2.5.7",
"tsx": "^4.15.5"
"zod": "^3.17.10"
}
}

View File

@ -1,12 +1,4 @@
import * as child_process from "child_process";
import { run } from "./shared/run";
(async () => {
run("yarn build");
run("npx build-storybook");
})();
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`);
child_process.execSync(command, { stdio: "inherit", ...options });
}
run("yarn build");
run("npx build-storybook");

View File

@ -7,7 +7,7 @@ import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../../src/bin/shared/constants";
import { assert, type Equals } from "tsafe/assert";
import * as fsPr from "fs/promises";
export async function createPublicDotKeycloakifyDir() {
export async function createPublicKeycloakifyDevResourcesDir() {
await Promise.all(
(["login", "account"] as const).map(async themeType => {
const { extractedDirPath } = await downloadKeycloakDefaultTheme({
@ -27,7 +27,7 @@ export async function createPublicDotKeycloakifyDir() {
"dist",
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY,
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES,
themeType
);

View File

@ -1,11 +1,12 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { assert } from "tsafe/assert";
import { transformCodebase } from "../../src/bin/tools/transformCodebase";
import { createPublicDotKeycloakifyDir } from "./createPublicDotKeycloakifyDir";
import { createPublicKeycloakifyDevResourcesDir } from "./createPublicKeycloakifyDevResourcesDir";
import { createAccountV1Dir } from "./createAccountV1Dir";
import chalk from "chalk";
import { run } from "../shared/run";
import { vendorFrontendDependencies } from "./vendorFrontendDependencies";
(async () => {
console.log(chalk.cyan("Building Keycloakify..."));
@ -88,6 +89,7 @@ import chalk from "chalk";
run(`npx tsc -p ${join("src", "tsconfig.json")}`);
run(`npx tsc-alias -p ${join("src", "tsconfig.json")}`);
vendorFrontendDependencies({ distDirPath: join(process.cwd(), "dist") });
if (fs.existsSync(join("dist", "vite-plugin", "index.original.js"))) {
fs.renameSync(
@ -144,7 +146,7 @@ import chalk from "chalk";
fs.cpSync(dirBasename, destDirPath, { recursive: true });
}
await createPublicDotKeycloakifyDir();
await createPublicKeycloakifyDevResourcesDir();
await createAccountV1Dir();
transformCodebase({
@ -164,12 +166,6 @@ import chalk from "chalk";
);
})();
function run(command: string) {
console.log(chalk.grey(`$ ${command}`));
child_process.execSync(command, { stdio: "inherit" });
}
function patchDeprecatedBufferApiUsage(filePath: string) {
const before = fs.readFileSync(filePath).toString("utf8");

View File

@ -0,0 +1,95 @@
import * as fs from "fs";
import { join as pathJoin, basename as pathBasename, dirname as pathDirname } from "path";
import { assert } from "tsafe/assert";
import { run } from "../shared/run";
import { cacheDirPath as cacheDirPath_base } from "../shared/cacheDirPath";
export function vendorFrontendDependencies(params: { distDirPath: string }) {
const { distDirPath } = params;
const vendorDirPath = pathJoin(distDirPath, "tools", "vendor");
const cacheDirPath = pathJoin(cacheDirPath_base, "vendorFrontendDependencies");
const extraBundleFileBasenames = new Set<string>();
fs.readdirSync(vendorDirPath)
.filter(fileBasename => fileBasename.endsWith(".js"))
.map(fileBasename => pathJoin(vendorDirPath, fileBasename))
.forEach(filePath => {
{
const mapFilePath = `${filePath}.map`;
if (fs.existsSync(mapFilePath)) {
fs.unlinkSync(mapFilePath);
}
}
if (!fs.existsSync(cacheDirPath)) {
fs.mkdirSync(cacheDirPath, { recursive: true });
}
const webpackConfigJsFilePath = pathJoin(cacheDirPath, "webpack.config.js");
const webpackOutputDirPath = pathJoin(cacheDirPath, "webpack_output");
const webpackOutputFilePath = pathJoin(webpackOutputDirPath, "index.js");
fs.writeFileSync(
webpackConfigJsFilePath,
Buffer.from(
[
``,
`module.exports = {`,
` mode: 'production',`,
` entry: Buffer.from("${Buffer.from(filePath, "utf8").toString("base64")}", "base64").toString("utf8"),`,
` output: {`,
` path: Buffer.from("${Buffer.from(webpackOutputDirPath, "utf8").toString("base64")}", "base64").toString("utf8"),`,
` filename: '${pathBasename(webpackOutputFilePath)}',`,
` libraryTarget: 'module',`,
` },`,
` target: "web",`,
` module: {`,
` rules: [`,
` {`,
` test: /\.js$/,`,
` use: {`,
` loader: 'babel-loader',`,
` options: {`,
` presets: ['@babel/preset-env'],`,
` }`,
` }`,
` }`,
` ]`,
` },`,
` experiments: {`,
` outputModule: true`,
` }`,
`};`
].join("\n")
)
);
run(`npx webpack --config ${webpackConfigJsFilePath}`);
fs.readdirSync(webpackOutputDirPath)
.filter(fileBasename => !fileBasename.endsWith(".txt"))
.map(fileBasename => pathJoin(webpackOutputDirPath, fileBasename))
.forEach(bundleFilePath => {
assert(bundleFilePath.endsWith(".js"));
if (pathBasename(bundleFilePath) === "index.js") {
fs.renameSync(webpackOutputFilePath, filePath);
} else {
const bundleFileBasename = pathBasename(bundleFilePath);
assert(!extraBundleFileBasenames.has(bundleFileBasename));
extraBundleFileBasenames.add(bundleFileBasename);
fs.renameSync(
bundleFilePath,
pathJoin(pathDirname(filePath), bundleFileBasename)
);
}
});
fs.rmSync(webpackOutputDirPath, { recursive: true });
});
}

View File

@ -6,6 +6,7 @@ import chalk from "chalk";
import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import { run } from "./shared/run";
(async () => {
{
@ -84,9 +85,3 @@ import { is } from "tsafe/is";
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
})();
function run(command: string) {
console.log(chalk.grey(`$ ${command}`));
return child_process.execSync(command, { stdio: "inherit" });
}

View File

@ -1,4 +1,3 @@
import "minimal-polyfills/Object.fromEntries";
import * as fs from "fs";
import {
join as pathJoin,
@ -13,7 +12,8 @@ import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTh
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import { deepAssign } from "../src/tools/deepAssign";
import { THEME_TYPES } from "../src/bin/shared/constants";
const propertiesParser: any = require("properties-parser");
import { transformCodebase } from "../src/bin/tools/transformCodebase";
import propertiesParser from "properties-parser";
if (require.main === module) {
generateI18nMessages();
@ -22,6 +22,17 @@ if (require.main === module) {
async function generateI18nMessages() {
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
const accountI18nDirPath = pathJoin(
thisCodebaseRootDirPath,
"src",
"account",
"i18n"
);
if (fs.existsSync(accountI18nDirPath)) {
fs.rmSync(accountI18nDirPath, { recursive: true });
}
type Dictionary = { [idiomId: string]: string };
const record: { [themeType: string]: { [language: string]: Dictionary } } = {};
@ -139,6 +150,26 @@ async function generateI18nMessages() {
"messages_defaultSet"
);
if (!fs.existsSync(messagesDirPath)) {
fs.mkdirSync(messagesDirPath, { recursive: true });
}
fs.writeFileSync(
pathJoin(messagesDirPath, "types.ts"),
Buffer.from(
[
``,
`export const languageTags = ${JSON.stringify(languages, null, 2)} as const;`,
``,
`export type LanguageTag = typeof languageTags[number];`,
``,
`export type MessageKey = keyof typeof import("./en")["default"];`,
``
].join("\n"),
"utf8"
)
);
const generatedFileHeader = [
`//This code was automatically generated by running ${pathRelative(
thisCodebaseRootDirPath,
@ -202,6 +233,18 @@ async function generateI18nMessages() {
)
);
}
transformCodebase({
srcDirPath: pathJoin(thisCodebaseRootDirPath, "src", "login", "i18n"),
destDirPath: accountI18nDirPath,
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (fileRelativePath.startsWith("messages_defaultSet")) {
return undefined;
}
return { modifiedSourceCode: sourceCode };
}
});
}
const keycloakifyExtraMessages_login: Record<

View File

@ -55,7 +55,6 @@ const commonThirdPartyDeps = [
Buffer.from(modifiedPackageJsonContent, "utf8")
);
}
const yarnGlobalDirPath = pathJoin(rootDirPath, ".yarn_home");
fs.rmSync(yarnGlobalDirPath, { recursive: true, force: true });
@ -64,6 +63,21 @@ fs.mkdirSync(yarnGlobalDirPath);
const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
const { targetModuleName, cwd } = params;
if (targetModuleName === undefined) {
const packageJsonFilePath = pathJoin(cwd, "package.json");
const packageJson = JSON.parse(
fs.readFileSync(packageJsonFilePath).toString("utf8")
);
delete packageJson["packageManager"];
fs.writeFileSync(
packageJsonFilePath,
Buffer.from(JSON.stringify(packageJson, null, 2))
);
}
const cmd = [
"yarn",
"link",
@ -77,7 +91,10 @@ const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
env: {
...process.env,
...(os.platform() === "win32"
? { USERPROFILE: yarnGlobalDirPath }
? {
USERPROFILE: yarnGlobalDirPath,
LOCALAPPDATA: yarnGlobalDirPath
}
: { HOME: yarnGlobalDirPath })
}
});

View File

@ -1,55 +1,88 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { join as pathJoin, sep as pathSep } from "path";
import { run } from "./shared/run";
import cliSelect from "cli-select";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import chalk from "chalk";
import { removeNodeModules } from "./tools/removeNodeModules";
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
import { crawl } from "../src/bin/tools/crawl";
{
const dirPath = "node_modules";
(async () => {
const parentDirPath = pathJoin(getThisCodebaseRootDirPath(), "..");
try {
fs.rmSync(dirPath, { recursive: true, force: true });
} catch {
// NOTE: This is a workaround for windows
// we can't remove locked executables.
const { starterName } = await (async () => {
const starterNames = fs
.readdirSync(parentDirPath)
.filter(
basename =>
basename.includes("starter") &&
basename.includes("keycloakify") &&
fs.statSync(pathJoin(parentDirPath, basename)).isDirectory()
);
crawl({
dirPath,
returnedPathsType: "absolute"
}).forEach(filePath => {
try {
fs.rmSync(filePath, { force: true });
} catch (error) {
if (filePath.endsWith(".exe")) {
return;
}
throw error;
if (starterNames.length === 0) {
console.log(
chalk.red(
`No starter found. Keycloakify Angular starter found in ${parentDirPath}`
)
);
process.exit(-1);
}
const starterName = await (async () => {
if (starterNames.length === 1) {
return starterNames[0];
}
});
}
}
fs.rmSync("dist", { recursive: true, force: true });
fs.rmSync(".yarn_home", { recursive: true, force: true });
console.log(chalk.cyan(`\nSelect a starter to link in:`));
run("yarn install");
run("yarn build");
const { value } = await cliSelect<string>({
values: starterNames.map(starterName => `..${pathSep}${starterName}`)
}).catch(() => {
process.exit(-1);
});
const starterName = "keycloakify-starter";
return value.split(pathSep)[1];
})();
fs.rmSync(join("..", starterName, "node_modules"), {
recursive: true,
force: true
});
return { starterName };
})();
run("yarn install", { cwd: join("..", starterName) });
const startTime = Date.now();
run(`npx tsx ${join("scripts", "link-in-app.ts")} ${starterName}`);
console.log(chalk.cyan(`\n\nLinking in ..${pathSep}${starterName}...`));
startRebuildOnSrcChange();
removeNodeModules({
nodeModulesDirPath: pathJoin(getThisCodebaseRootDirPath(), "node_modules")
});
function run(command: string, options?: { cwd: string }) {
console.log(`$ ${command}`);
fs.rmSync(pathJoin(getThisCodebaseRootDirPath(), "dist"), {
recursive: true,
force: true
});
fs.rmSync(pathJoin(getThisCodebaseRootDirPath(), ".yarn_home"), {
recursive: true,
force: true
});
child_process.execSync(command, { stdio: "inherit", ...options });
}
run("yarn install");
run("yarn build");
const starterDirPath = pathJoin(parentDirPath, starterName);
removeNodeModules({
nodeModulesDirPath: pathJoin(starterDirPath, "node_modules")
});
run("yarn install", { cwd: starterDirPath });
run(`npx tsx ${pathJoin("scripts", "link-in-app.ts")} ${starterName}`);
const durationSeconds = Math.round((Date.now() - startTime) / 1000);
await new Promise(resolve => setTimeout(resolve, 1000));
startRebuildOnSrcChange();
console.log(chalk.green(`\n\nLinked in ${starterName} in ${durationSeconds}s`));
})();

View File

@ -0,0 +1,9 @@
import { join as pathJoin } from "path";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
export const cacheDirPath = pathJoin(
getThisCodebaseRootDirPath(),
"node_modules",
".cache",
"scripts"
);

View File

@ -2,8 +2,9 @@ import { relative as pathRelative } from "path";
import { downloadAndExtractArchive } from "../../src/bin/tools/downloadAndExtractArchive";
import { getProxyFetchOptions } from "../../src/bin/tools/fetchProxyOptions";
import { join as pathJoin } from "path";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
import { assert, type Equals } from "tsafe/assert";
import { cacheDirPath } from "./cacheDirPath";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
const KEYCLOAK_VERSION = {
FOR_LOGIN_THEME: "25.0.4",
@ -22,12 +23,7 @@ export async function downloadKeycloakDefaultTheme(params: {
const { extractedDirPath } = await downloadAndExtractArchive({
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
cacheDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"node_modules",
".cache",
"scripts"
),
cacheDirPath,
fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: getThisCodebaseRootDirPath()
}),

8
scripts/shared/run.ts Normal file
View File

@ -0,0 +1,8 @@
import * as child_process from "child_process";
import chalk from "chalk";
export function run(command: string, options?: { cwd: string }) {
console.log(chalk.grey(`$ ${command}`));
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

@ -1,5 +1,6 @@
import * as child_process from "child_process";
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
import { run } from "./shared/run";
(async () => {
run("yarn build");
@ -18,9 +19,3 @@ import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
startRebuildOnSrcChange();
})();
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`);
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

@ -0,0 +1,27 @@
import * as fs from "fs";
import { crawl } from "../../src/bin/tools/crawl";
export function removeNodeModules(params: { nodeModulesDirPath: string }) {
const { nodeModulesDirPath } = params;
try {
fs.rmSync(nodeModulesDirPath, { recursive: true, force: true });
} catch {
// NOTE: This is a workaround for windows
// we can't remove locked executables.
crawl({
dirPath: nodeModulesDirPath,
returnedPathsType: "absolute"
}).forEach(filePath => {
try {
fs.rmSync(filePath, { force: true });
} catch (error) {
if (filePath.endsWith(".exe")) {
return;
}
throw error;
}
});
}
}

View File

@ -3,8 +3,10 @@ import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "keycloakify/bin/shared/constants
import { id } from "tsafe/id";
import type { KcContext } from "./KcContext";
import { BASE_URL } from "keycloakify/lib/BASE_URL";
import { assert, type Equals } from "tsafe/assert";
import type { LanguageTag } from "keycloakify/account/i18n/messages_defaultSet/types";
const resourcesPath = `${BASE_URL}${WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY}/account`;
const resourcesPath = `${BASE_URL}${WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES}/account`;
export const kcContextCommonMock: KcContext.Common = {
themeVersion: "0.0.0",
@ -38,35 +40,53 @@ export const kcContextCommonMock: KcContext.Common = {
exists: () => false
},
locale: {
supported: [
/* spell-checker: disable */
["de", "Deutsch"],
["no", "Norsk"],
["ru", "Русский"],
["sv", "Svenska"],
["pt-BR", "Português (Brasil)"],
["lt", "Lietuvių"],
["en", "English"],
["it", "Italiano"],
["fr", "Français"],
["zh-CN", "中文简体"],
["es", "Español"],
["cs", "Čeština"],
["ja", "日本語"],
["sk", "Slovenčina"],
["pl", "Polski"],
["ca", "Català"],
["nl", "Nederlands"],
["tr", "Türkçe"]
/* spell-checker: enable */
].map(
([languageTag, label]) =>
({
languageTag,
label,
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
}) as const
),
supported: (
[
/* spell-checker: disable */
["de", "Deutsch"],
["no", "Norsk"],
["ru", "Русский"],
["sv", "Svenska"],
["pt-BR", "Português (Brasil)"],
["lt", "Lietuvių"],
["en", "English"],
["it", "Italiano"],
["fr", "Français"],
["zh-CN", "中文简体"],
["es", "Español"],
["cs", "Čeština"],
["ja", "日本語"],
["sk", "Slovenčina"],
["pl", "Polski"],
["ca", "Català"],
["nl", "Nederlands"],
["tr", "Türkçe"],
["ar", "العربية"],
["da", "Dansk"],
["fi", "Suomi"],
["hu", "Magyar"],
["lv", "Latviešu"]
/* spell-checker: enable */
] as const
).map(([languageTag, label]) => {
{
type Got = typeof languageTag;
type Expected = LanguageTag;
type Missing = Exclude<Expected, Got>;
type Unexpected = Exclude<Got, Expected>;
assert<Equals<Missing, never>>;
assert<Equals<Unexpected, never>>;
}
return {
languageTag,
label,
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
} as const;
}),
currentLanguageTag: "en"
},
features: {

View File

@ -1,9 +1,9 @@
import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import { useInitialize } from "keycloakify/account/Template.useInitialize";
import type { TemplateProps } from "keycloakify/account/TemplateProps";
import type { I18n } from "./i18n";
import type { KcContext } from "./KcContext";
@ -13,9 +13,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { msg, msgStr, getChangeLocaleUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { msg, msgStr, currentLanguage, enabledLanguages } = i18n;
const { locale, url, features, realm, message, referrer } = kcContext;
const { url, features, realm, message, referrer } = kcContext;
useEffect(() => {
document.title = msgStr("accountManagementTitle");
@ -31,30 +31,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
className: clsx("admin-console", "user", kcClsx("kcBodyClass"))
});
useEffect(() => {
const { currentLanguageTag } = locale ?? {};
const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss });
if (currentLanguageTag === undefined) {
return;
}
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
}, []);
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
hrefs: !doUseDefaultCss
? []
: [
`${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`
]
});
if (!areAllStyleSheetsLoaded) {
if (!isReadyToRender) {
return null;
}
@ -70,16 +49,16 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<div className="navbar-collapse navbar-collapse-1">
<div className="container">
<ul className="nav navbar-nav navbar-utility">
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
{enabledLanguages.length > 1 && (
<li>
<div className="kc-dropdown" id="kc-locale-dropdown">
<a href="#" id="kc-current-locale-link">
{labelBySupportedLanguageTag[currentLanguageTag]}
{currentLanguage.label}
</a>
<ul>
{locale.supported.map(({ languageTag }) => (
{enabledLanguages.map(({ languageTag, label, href }) => (
<li key={languageTag} className="kc-dropdown-item">
<a href={getChangeLocaleUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
<a href={href}>{label}</a>
</li>
))}
</ul>
@ -148,7 +127,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<span
className="kc-feedback-text"
dangerouslySetInnerHTML={{
__html: message.summary
__html: kcSanitize(message.summary)
}}
/>
</div>

View File

@ -0,0 +1,35 @@
import { assert } from "keycloakify/tools/assert";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import type { KcContext } from "keycloakify/account/KcContext";
export type KcContextLike = {
url: {
resourcesCommonPath: string;
resourcesPath: string;
};
};
assert<keyof KcContextLike extends keyof KcContext ? true : false>();
assert<KcContext extends KcContextLike ? true : false>();
export function useInitialize(params: {
kcContext: KcContextLike;
doUseDefaultCss: boolean;
}) {
const { kcContext, doUseDefaultCss } = params;
const { url } = kcContext;
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
hrefs: !doUseDefaultCss
? []
: [
`${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`
]
});
return { isReadyToRender: areAllStyleSheetsLoaded };
}

View File

@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import type { ClassKey } from "keycloakify/account/lib/kcClsx";
export type TemplateProps<KcContext, I18n> = {
kcContext: KcContext;
@ -10,17 +11,4 @@ export type TemplateProps<KcContext, I18n> = {
active: string;
};
export type ClassKey =
| "kcHtmlClass"
| "kcBodyClass"
| "kcButtonClass"
| "kcButtonPrimaryClass"
| "kcButtonLargeClass"
| "kcButtonDefaultClass"
| "kcContentWrapperClass"
| "kcFormClass"
| "kcFormGroupClass"
| "kcInputWrapperClass"
| "kcLabelClass"
| "kcInputClass"
| "kcInputErrorMessageClass";
export type { ClassKey };

View File

@ -1,6 +0,0 @@
import type { GenericI18n_noJsx } from "./i18n";
export type GenericI18n<MessageKey extends string> = GenericI18n_noJsx<MessageKey> & {
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
};

View File

@ -1,250 +0,0 @@
import "keycloakify/tools/Object.fromEntries";
import { assert } from "tsafe/assert";
import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en";
import { fetchMessages_defaultSet } from "./messages_defaultSet";
import type { KcContext } from "../KcContext";
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
export type KcContextLike = {
locale?: {
currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[];
};
"x-keycloakify": {
messages: Record<string, string>;
};
};
assert<KcContext extends KcContextLike ? true : false>();
export type GenericI18n_noJsx<MessageKey extends string> = {
/**
* e.g: "en", "fr", "zh-CN"
*
* The current language
*/
currentLanguageTag: string;
/**
* Redirect to this url to change the language.
* After reload currentLanguageTag === newLanguageTag
*/
getChangeLocaleUrl: (newLanguageTag: string) => string;
/**
* e.g. "en" => "English", "fr" => "Français", ...
*
* Used to render a select that enable user to switch language.
* ex: https://user-images.githubusercontent.com/6702424/186044799-38801eec-4e89-483b-81dd-8e9233e8c0eb.png
* */
labelBySupportedLanguageTag: Record<string, string>;
/**
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
* msgStr("${bar}", "<strong>c</strong>") === "Bar &lt;strong&gt;XXX&lt;/strong&gt;"
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
*/
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
/**
* This is meant to be used when the key argument is variable, something that might have been configured by the user
* in the Keycloak admin for example.
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* }
* }
*
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
};
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
export function createGetI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag_themeDefined: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
function getI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
use_cache: {
const cachedResult = cachedResultByKcContext.get(kcContext);
if (cachedResult === undefined) {
break use_cache;
}
return cachedResult;
}
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocaleUrl" | "labelBySupportedLanguageTag"> = {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG,
getChangeLocaleUrl: newLanguageTag => {
const { locale } = kcContext;
assert(locale !== undefined, "Internationalization not enabled");
const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag);
assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`);
return targetSupportedLocale.url;
},
labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]))
};
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
messages_themeDefined:
messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ??
messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG] ??
(() => {
const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0];
if (firstLanguageTag === undefined) {
return undefined;
}
return messagesByLanguageTag_themeDefined[firstLanguageTag];
})(),
messages_fromKcServer: kcContext["x-keycloakify"].messages
});
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === FALLBACK_LANGUAGE_TAG;
const result: Result = {
i18n: {
...partialI18n,
...createI18nTranslationFunctions({
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
}),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag);
const i18n_currentLanguage: I18n = {
...partialI18n,
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
isFetchingTranslations: false
};
// NOTE: This promise.resolve is just because without it we TypeScript
// gives a Variable 'result' is used before being assigned. error
await Promise.resolve().then(() => {
result.i18n = i18n_currentLanguage;
result.prI18n_currentLanguage = undefined;
});
return i18n_currentLanguage;
})()
};
cachedResultByKcContext.set(kcContext, result);
return result;
}
return { getI18n };
}
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
messages_themeDefined: Record<MessageKey_themeDefined, string> | undefined;
messages_fromKcServer: Record<string, string>;
}) {
const { messages_themeDefined, messages_fromKcServer } = params;
function createI18nTranslationFunctions(params: {
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>, "msgStr" | "advancedMsgStr"> {
const { messages_defaultSet_currentLanguage } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
const { key, args } = props;
const message =
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
id<Record<string, string | undefined> | undefined>(messages_themeDefined)?.[key] ??
id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];
if (message === undefined) {
return undefined;
}
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
if (startIndex === undefined) {
// No {0} in message (no arguments expected)
return message;
}
let messageWithArgsInjected = message;
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
});
return messageWithArgsInjected;
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
const { key, args } = props;
const match = key.match(/^\$\{(.+)\}$/);
if (match === null) {
return key;
}
return resolveMsg({ key: match[1], args }) ?? key;
}
return {
msgStr: (key, ...args) => {
const resolvedMessage = resolveMsg({ key, args });
assert(resolvedMessage !== undefined, `Message with key "${key}" not found`);
return resolvedMessage;
},
advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args })
};
}
return { createI18nTranslationFunctions };
}

View File

@ -1,5 +0,0 @@
import type { GenericI18n } from "./GenericI18n";
import type { MessageKey_defaultSet, KcContextLike } from "./i18n";
export type { MessageKey_defaultSet, KcContextLike };
export type I18n = GenericI18n<MessageKey_defaultSet>;
export { createUseI18n } from "./useI18n";

View File

@ -1,95 +0,0 @@
import { useEffect, useState } from "react";
import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n";
import { GenericI18n } from "./GenericI18n";
import { Reflect } from "tsafe/Reflect";
export function createUseI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
type I18n = GenericI18n<MessageKey>;
const { withJsx } = (() => {
const cache = new WeakMap<GenericI18n_noJsx<MessageKey>, GenericI18n<MessageKey>>();
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
const { htmlString, msgKey } = params;
return (
<div
data-kc-msg={msgKey}
dangerouslySetInnerHTML={{
__html: htmlString
}}
/>
);
}
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey>): I18n {
use_cache: {
const i18n = cache.get(i18n_noJsx);
if (i18n === undefined) {
break use_cache;
}
return i18n;
}
const i18n: I18n = {
...i18n_noJsx,
msg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.msgStr(msgKey, ...args), msgKey }),
advancedMsg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.advancedMsgStr(msgKey, ...args), msgKey })
};
cache.set(i18n_noJsx, i18n);
return i18n;
}
return { withJsx };
})();
add_style: {
const attributeName = "data-kc-i18n";
// Check if already exists in head
if (document.querySelector(`style[${attributeName}]`) !== null) {
break add_style;
}
const styleElement = document.createElement("style");
styleElement.attributes.setNamedItem(document.createAttribute(attributeName));
(styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement);
}
const { getI18n } = createGetI18n(messagesByLanguageTag);
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params;
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(withJsx(i18n));
useEffect(() => {
let isActive = true;
prI18n_currentLanguage?.then(i18n => {
if (!isActive) {
return;
}
setI18n_toReturn(withJsx(i18n));
});
return () => {
isActive = false;
};
}, []);
return { i18n: i18n_toReturn };
}
return { useI18n, ofTypeI18n: Reflect<I18n>() };
}

View File

@ -1,3 +1,3 @@
export type { ExtendKcContext } from "keycloakify/account/KcContext";
export type { ClassKey } from "keycloakify/account/TemplateProps";
export { createUseI18n } from "keycloakify/account/i18n";
export { i18nBuilder, type MessageKey_defaultSet } from "keycloakify/account/i18n";

View File

@ -1,5 +1,19 @@
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
import type { ClassKey } from "keycloakify/account/TemplateProps";
export type ClassKey =
| "kcHtmlClass"
| "kcBodyClass"
| "kcButtonClass"
| "kcButtonPrimaryClass"
| "kcButtonLargeClass"
| "kcButtonDefaultClass"
| "kcContentWrapperClass"
| "kcFormClass"
| "kcFormGroupClass"
| "kcInputWrapperClass"
| "kcLabelClass"
| "kcInputClass"
| "kcInputErrorMessageClass";
export const { getKcClsx } = createGetKcClsx<ClassKey>({
defaultClasses: {
@ -20,6 +34,4 @@ export const { getKcClsx } = createGetKcClsx<ClassKey>({
}
});
export type { ClassKey };
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];

View File

@ -1,5 +1,6 @@
import { clsx } from "keycloakify/tools/clsx";
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/account/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -159,7 +160,7 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("totp")
__html: kcSanitize(messagesPerField.get("totp"))
}}
/>
)}
@ -190,7 +191,7 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("userLabel")
__html: kcSanitize(messagesPerField.get("userLabel"))
}}
/>
)}

View File

@ -13,17 +13,23 @@ import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import type { BuildContext } from "./shared/buildContext";
import chalk from "chalk";
import { runFormat } from "./tools/runFormat";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const buildContext = getBuildContext({
cliCommandOptions
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "add-story",
buildContext
});
if (hasBeenHandled) {
return;
}
console.log(chalk.cyan("Theme type:"));
const themeType = await (async () => {
@ -126,6 +132,10 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
runFormat({
packageJsonFilePath: buildContext.packageJsonFilePath
});
console.log(
[
`${chalk.green("✓")} ${chalk.bold(

View File

@ -1,13 +1,96 @@
import { copyKeycloakResourcesToPublic } from "./shared/copyKeycloakResourcesToPublic";
import { getBuildContext } from "./shared/buildContext";
import type { CliCommandOptions } from "./main";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import { join as pathJoin, dirname as pathDirname } from "path";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "./shared/constants";
import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
import * as fs from "fs";
import { rmSync } from "./tools/fs.rmSync";
import type { BuildContext } from "./shared/buildContext";
import { transformCodebase } from "./tools/transformCodebase";
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const buildContext = getBuildContext({ cliCommandOptions });
copyKeycloakResourcesToPublic({
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "copy-keycloak-resources-to-public",
buildContext
});
if (hasBeenHandled) {
return;
}
const destDirPath = pathJoin(
buildContext.publicDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
const keycloakifyBuildinfoRaw = JSON.stringify(
{
keycloakifyVersion: readThisNpmPackageVersion()
},
null,
2
);
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;
}
return;
}
rmSync(destDirPath, { force: true, recursive: true });
// NOTE: To remove in a while, remove the legacy keycloak-resources directory
rmSync(pathJoin(pathDirname(destDirPath), "keycloak-resources"), {
force: true,
recursive: true
});
rmSync(pathJoin(pathDirname(destDirPath), ".keycloakify"), {
force: true,
recursive: true
});
fs.mkdirSync(destDirPath, { recursive: true });
fs.writeFileSync(pathJoin(destDirPath, ".gitignore"), Buffer.from("*", "utf8"));
transformCodebase({
srcDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
),
destDirPath
});
fs.writeFileSync(
pathJoin(destDirPath, "README.txt"),
Buffer.from(
// prettier-ignore
[
"This directory is only used in dev mode by Keycloakify",
"It won't be included in your final build.",
"Do not modify anything in this directory.",
].join("\n")
)
);
fs.writeFileSync(
keycloakifyBuildinfoFilePath,
Buffer.from(keycloakifyBuildinfoRaw, "utf8")
);
}

View File

@ -20,17 +20,23 @@ import {
} from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import type { BuildContext } from "./shared/buildContext";
import chalk from "chalk";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import { runFormat } from "./tools/runFormat";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const buildContext = getBuildContext({
cliCommandOptions
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "eject-page",
buildContext
});
if (hasBeenHandled) {
return;
}
console.log(chalk.cyan("Theme type:"));
const themeType = await (async () => {
@ -238,18 +244,22 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
runFormat({
packageJsonFilePath: buildContext.packageJsonFilePath
});
console.log(
`${chalk.green("✓")} ${chalk.bold(
pathJoin(".", pathRelative(process.cwd(), targetFilePath))
)} copy pasted from the Keycloakify source code into your project`
);
edit_KcApp: {
edit_KcPage: {
if (
pageIdOrComponent !== templateValue &&
pageIdOrComponent !== userProfileFormFieldsValue
) {
break edit_KcApp;
break edit_KcPage;
}
const kcAppTsxPath = pathJoin(

View File

@ -1,17 +1,24 @@
import { getBuildContext } from "../shared/buildContext";
import type { CliCommandOptions } from "../main";
import type { BuildContext } from "../shared/buildContext";
import cliSelect from "cli-select";
import child_process from "child_process";
import chalk from "chalk";
import { join as pathJoin, relative as pathRelative } from "path";
import * as fs from "fs";
import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeImplementationInConfig";
import { generateKcGenTs } from "../shared/generateKcGenTs";
import { command as updateKcGenCommand } from "../update-kc-gen";
import { maybeDelegateCommandToCustomHandler } from "../shared/customHandler_delegate";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const buildContext = getBuildContext({ cliCommandOptions });
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "initialize-account-theme",
buildContext
});
if (hasBeenHandled) {
return;
}
const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
@ -97,7 +104,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
updateAccountThemeImplementationInConfig({ buildContext, accountThemeType });
await generateKcGenTs({
await updateKcGenCommand({
buildContext: {
...buildContext,
implementedThemeTypes: {

View File

@ -1,5 +1,12 @@
import { createUseI18n } from "keycloakify/account";
import { i18nBuilder } from "keycloakify/account";
import type { ThemeName } from "../kc.gen";
export const { useI18n, ofTypeI18n } = createUseI18n({});
const { useI18n, ofTypeI18n } = i18nBuilder
.withThemeName<ThemeName>()
.withExtraLanguages({})
.withCustomTranslations({})
.build();
export type I18n = typeof ofTypeI18n;
type I18n = typeof ofTypeI18n;
export { useI18n, type I18n };

View File

@ -5,15 +5,18 @@ import * as fs from "fs";
import chalk from "chalk";
import { z } from "zod";
import { id } from "tsafe/id";
import { is } from "tsafe/is";
export type BuildContextLike = {
bundler: BuildContext["bundler"];
projectDirPath: string;
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function updateAccountThemeImplementationInConfig(params: {
buildContext: BuildContext;
buildContext: BuildContextLike;
accountThemeType: "Single-Page" | "Multi-Page";
}) {
const { buildContext, accountThemeType } = params;
@ -81,6 +84,8 @@ export function updateAccountThemeImplementationInConfig(params: {
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
})();

View File

@ -1,15 +1,25 @@
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
import { getBuildContext } from "./shared/buildContext";
import type { BuildContext } from "./shared/buildContext";
import * as fs from "fs";
import type { CliCommandOptions } from "./main";
import { downloadAndExtractArchive } from "./tools/downloadAndExtractArchive";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import fetch from "make-fetch-happen";
import { SemVer } from "./tools/SemVer";
import { assert } from "tsafe/assert";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const buildContext = getBuildContext({ cliCommandOptions });
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "initialize-email-theme",
buildContext
});
if (hasBeenHandled) {
return;
}
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
@ -29,7 +39,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log("Initialize with the base email theme from which version of Keycloak?");
const { keycloakVersion } = await promptKeycloakVersion({
let { keycloakVersion } = await promptKeycloakVersion({
// NOTE: This is arbitrary
startingFromMajor: 17,
excludeMajorVersions: [],
@ -37,8 +47,32 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
buildContext
});
const getUrl = (keycloakVersion: string) => {
return `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`;
};
keycloakVersion = await (async () => {
const keycloakVersionParsed = SemVer.parse(keycloakVersion);
while (true) {
const url = getUrl(SemVer.stringify(keycloakVersionParsed));
const response = await fetch(url, buildContext.fetchOptions);
if (response.ok) {
break;
}
assert(keycloakVersionParsed.patch !== 0);
keycloakVersionParsed.patch--;
}
return SemVer.stringify(keycloakVersionParsed);
})();
const { extractedDirPath } = await downloadAndExtractArchive({
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
url: getUrl(keycloakVersion),
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "extractOnlyEmailTheme",

View File

@ -16,6 +16,7 @@ import { isInside } from "../../tools/isInside";
import child_process from "child_process";
import { rmSync } from "../../tools/fs.rmSync";
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
import { existsAsync } from "../../tools/fs.existsAsync";
export type BuildContextLike = BuildContextLike_generatePom & {
keycloakifyBuildDirPath: string;
@ -135,40 +136,49 @@ export async function buildJar(params: {
break route_legacy_pages;
}
(["register.ftl", "login-update-profile.ftl"] as const).forEach(pageId =>
buildContext.themeNames.map(themeName => {
const ftlFilePath = pathJoin(
tmpResourcesDirPath,
"theme",
themeName,
"login",
pageId
);
await Promise.all(
(["register.ftl", "login-update-profile.ftl"] as const)
.map(pageId =>
buildContext.themeNames.map(async themeName => {
const ftlFilePath = pathJoin(
tmpResourcesDirPath,
"theme",
themeName,
"login",
pageId
);
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
// NOTE: https://github.com/keycloakify/keycloakify/issues/665
if (!(await existsAsync(ftlFilePath))) {
return;
}
const ftlFileBasename = (() => {
switch (pageId) {
case "register.ftl":
return "register-user-profile.ftl";
case "login-update-profile.ftl":
return "update-user-profile.ftl";
}
assert<Equals<typeof pageId, never>>(false);
})();
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
const modifiedFtlFileContent = ftlFileContent.replace(
`"ftlTemplateFileName": "${pageId}"`,
`"ftlTemplateFileName": "${ftlFileBasename}"`
);
const ftlFileBasename = (() => {
switch (pageId) {
case "register.ftl":
return "register-user-profile.ftl";
case "login-update-profile.ftl":
return "update-user-profile.ftl";
}
assert<Equals<typeof pageId, never>>(false);
})();
assert(modifiedFtlFileContent !== ftlFileContent);
const modifiedFtlFileContent = ftlFileContent.replace(
`"ftlTemplateFileName": "${pageId}"`,
`"ftlTemplateFileName": "${ftlFileBasename}"`
);
fs.writeFile(
pathJoin(pathDirname(ftlFilePath), ftlFileBasename),
Buffer.from(modifiedFtlFileContent, "utf8")
);
})
assert(modifiedFtlFileContent !== ftlFileContent);
await fs.writeFile(
pathJoin(pathDirname(ftlFilePath), ftlFileBasename),
Buffer.from(modifiedFtlFileContent, "utf8")
);
})
)
.flat()
);
}
@ -187,7 +197,7 @@ export async function buildJar(params: {
await new Promise<void>((resolve, reject) =>
child_process.exec(
`mvn install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`,
`mvn clean install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`,
{ cwd: keycloakifyBuildCacheDirPath },
error => {
if (error !== null) {

View File

@ -52,9 +52,9 @@ export function getKeycloakVersionRangeForJar(params: {
case "0.6":
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return undefined;
return "26-and-above" as const;
case "1.1.5":
return "25-and-above" as const;
return "25" as const;
}
}
assert<Equals<typeof keycloakAccountV1Version, never>>(false);
@ -75,9 +75,9 @@ export function getKeycloakVersionRangeForJar(params: {
}
switch (keycloakThemeAdditionalInfoExtensionVersion) {
case null:
return "21-and-below";
return "all-other-versions";
case "1.1.5":
return "22-and-above";
return "22-to-25";
}
assert<Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>>(
false

View File

@ -85,6 +85,21 @@ attributes_to_attributesByName: {
});
}
window.kcContext = kcContext;
<#if xKeycloakify.themeType == "login" >
{
const script = document.createElement("script");
script.type = "importmap";
script.textContent = JSON.stringify({
imports: {
"rfc4648": kcContext.url.resourcesCommonPath + "/node_modules/rfc4648/lib/rfc4648.js"
}
}, null, 2);
document.head.appendChild(script);
}
</#if>
function decodeHtmlEntities(htmlStr){
var element = decodeHtmlEntities.element;
if (!element) {
@ -151,7 +166,7 @@ function decodeHtmlEntities(htmlStr){
areSamePath(path, []) &&
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(xKeycloakify.pageId)
) || (
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
["masterAdminClient", "delegateForUpdate", "defaultRole", "smtpConfig"]?seq_contains(key) &&
areSamePath(path, ["realm"])
) || (
xKeycloakify.pageId == "error.ftl" &&
@ -220,6 +235,9 @@ function decodeHtmlEntities(htmlStr){
"identityFederationEnabled",
"userManagedAccessAllowed"
]?seq_contains(key)
) || (
["flowContext", "session", "realm"]?seq_contains(key) &&
areSamePath(path, ["social"])
)
>
<#-- <#local outSeq += ["/*" + path?join(".") + "." + key + " excluded*/"]> -->

View File

@ -1,6 +1,6 @@
import { type ThemeType, FALLBACK_LANGUAGE_TAG } from "../../shared/constants";
import { crawl } from "../../tools/crawl";
import { join as pathJoin } from "path";
import { join as pathJoin, dirname as pathDirname } from "path";
import { symToStr } from "tsafe/symToStr";
import * as recast from "recast";
import * as babelParser from "@babel/parser";
@ -10,12 +10,27 @@ import { escapeStringForPropertiesFile } from "../../tools/escapeStringForProper
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
import * as fs from "fs";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../../shared/buildContext";
import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath";
export type BuildContextLike = {
themeNames: string[];
themeSrcDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function generateMessageProperties(params: {
themeSrcDirPath: string;
buildContext: BuildContextLike;
themeType: ThemeType;
}): { languageTag: string; propertiesFileSource: string }[] {
const { themeSrcDirPath, themeType } = params;
}): {
languageTags: string[];
writeMessagePropertiesFiles: (params: {
messageDirPath: string;
themeName: string;
}) => void;
} {
const { buildContext, themeType } = params;
const baseMessagesDirPath = pathJoin(
getThisCodebaseRootDirPath(),
@ -25,51 +40,49 @@ export function generateMessageProperties(params: {
"messages_defaultSet"
);
const baseMessageBundle: { [languageTag: string]: Record<string, string> } =
Object.fromEntries(
fs
.readdirSync(baseMessagesDirPath)
.filter(baseName => baseName !== "index.ts")
.map(basename => ({
languageTag: basename.replace(/\.ts$/, ""),
filePath: pathJoin(baseMessagesDirPath, basename)
}))
.map(({ languageTag, filePath }) => {
const lines = fs
.readFileSync(filePath)
.toString("utf8")
.split(/\r?\n/);
const messages_defaultSet_by_languageTag_defaultSet: {
[languageTag_defaultSet: string]: Record<string, string>;
} = Object.fromEntries(
fs
.readdirSync(baseMessagesDirPath)
.filter(basename => basename !== "index.ts" && basename !== "types.ts")
.map(basename => ({
languageTag: basename.replace(/\.ts$/, ""),
filePath: pathJoin(baseMessagesDirPath, basename)
}))
.map(({ languageTag, filePath }) => {
const lines = fs.readFileSync(filePath).toString("utf8").split(/\r?\n/);
let messagesJson = "{";
let messagesJson = "{";
let isInDeclaration = false;
let isInDeclaration = false;
for (const line of lines) {
if (!isInDeclaration) {
if (line.startsWith("const messages")) {
isInDeclaration = true;
}
continue;
for (const line of lines) {
if (!isInDeclaration) {
if (line.startsWith("const messages")) {
isInDeclaration = true;
}
if (line.startsWith("}")) {
messagesJson += "}";
break;
}
messagesJson += line;
continue;
}
const messages = JSON.parse(messagesJson) as Record<string, string>;
if (line.startsWith("}")) {
messagesJson += "}";
break;
}
return [languageTag, messages];
})
);
messagesJson += line;
}
const messages = JSON.parse(messagesJson) as Record<string, string>;
return [languageTag, messages];
})
);
const { i18nTsFilePath } = (() => {
let files = crawl({
dirPath: pathJoin(themeSrcDirPath, themeType),
dirPath: pathJoin(buildContext.themeSrcDirPath, themeType),
returnedPathsType: "absolute"
});
@ -88,7 +101,7 @@ export function generateMessageProperties(params: {
files = files.sort((a, b) => a.length - b.length);
files = files.filter(file =>
fs.readFileSync(file).toString("utf8").includes("createUseI18n(")
fs.readFileSync(file).toString("utf8").includes("i18nBuilder")
);
const i18nTsFilePath: string | undefined = files[0];
@ -96,97 +109,334 @@ export function generateMessageProperties(params: {
return { i18nTsFilePath };
})();
const messageBundle: { [languageTag: string]: Record<string, string> } | undefined =
(() => {
if (i18nTsFilePath === undefined) {
return undefined;
}
const i18nTsRoot = (() => {
if (i18nTsFilePath === undefined) {
return undefined;
}
const root = recastParseTs(i18nTsFilePath);
return root;
})();
const root = recast.parse(fs.readFileSync(i18nTsFilePath).toString("utf8"), {
parser: {
parse: (code: string) =>
babelParser.parse(code, {
sourceType: "module",
plugins: ["typescript"]
}),
generator: babelGenerate,
types: babelTypes
}
});
const messages_defaultSet_by_languageTag_notInDefaultSet:
| { [languageTag_notInDefaultSet: string]: Record<string, string> }
| undefined = (() => {
if (i18nTsRoot === undefined) {
return undefined;
}
let messageBundleDeclarationTsCode: string | undefined = undefined;
let extraLanguageEntryByLanguageTag: Record<
string,
{ label: string; path: string }
> = {};
recast.visit(root, {
visitCallExpression: function (path) {
if (
path.node.callee.type === "Identifier" &&
path.node.callee.name === "createUseI18n"
) {
messageBundleDeclarationTsCode = babelGenerate(
path.node.arguments[0] as any
).code;
return false;
recast.visit(i18nTsRoot, {
visitCallExpression: function (path) {
const node = path.node;
// Check if the callee is a MemberExpression with property 'withExtraLanguages'
if (
node.callee.type === "MemberExpression" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "withExtraLanguages"
) {
const arg = node.arguments[0];
if (arg && arg.type === "ObjectExpression") {
// Iterate over the properties of the object
arg.properties.forEach(prop => {
if (
prop.type === "ObjectProperty" &&
prop.key.type === "Identifier"
) {
const lang = prop.key.name;
const value = prop.value;
if (value.type === "ObjectExpression") {
let label: string | undefined = undefined;
let pathStr: string | undefined = undefined;
// Iterate over the properties of the language object
value.properties.forEach(p => {
if (
p.type === "ObjectProperty" &&
p.key.type === "Identifier"
) {
if (
p.key.name === "label" &&
p.value.type === "StringLiteral"
) {
label = p.value.value;
}
if (
p.key.name === "getMessages" &&
(p.value.type ===
"ArrowFunctionExpression" ||
p.value.type === "FunctionExpression")
) {
// Extract the import path from the function body
const body = p.value.body;
if (
body.type === "CallExpression" &&
body.callee.type === "Import"
) {
const importArg = body.arguments[0];
if (
importArg.type === "StringLiteral"
) {
pathStr = importArg.value;
}
} else if (
body.type === "BlockStatement"
) {
// If the function body is a block (e.g., function with braces {})
// Look for return statement
body.body.forEach(statement => {
if (
statement.type ===
"ReturnStatement" &&
statement.argument &&
statement.argument.type ===
"CallExpression" &&
statement.argument.callee
.type === "Import"
) {
const importArg =
statement.argument
.arguments[0];
if (
importArg.type ===
"StringLiteral"
) {
pathStr = importArg.value;
}
}
});
}
}
}
});
if (label && pathStr) {
extraLanguageEntryByLanguageTag[lang] = {
label,
path: pathStr
};
}
}
}
});
}
this.traverse(path);
return false; // Stop traversing this path
}
});
assert(messageBundleDeclarationTsCode !== undefined);
let messageBundle: {
[languageTag: string]: Record<string, string>;
} = {};
try {
eval(
`${symToStr({ messageBundle })} = ${messageBundleDeclarationTsCode}`
);
} catch {
console.warn(
[
"WARNING: Make sure the messageBundle your provided as argument of createUseI18n can be statically evaluated.",
"This is important because we need to put your i18n messages in messages_*.properties files",
"or they won't be available server side.",
"\n",
"The following code could not be evaluated:",
"\n",
messageBundleDeclarationTsCode
].join(" ")
);
this.traverse(path); // Continue traversing other paths
}
});
return messageBundle;
})();
const messages_defaultSet_by_languageTag_notInDefaultSet = Object.fromEntries(
Object.entries(extraLanguageEntryByLanguageTag).map(
([languageTag, { path: relativePathWithoutExt }]) => [
languageTag,
(() => {
const filePath = getAbsoluteAndInOsFormatPath({
pathIsh: relativePathWithoutExt.endsWith(".ts")
? relativePathWithoutExt
: `${relativePathWithoutExt}.ts`,
cwd: pathDirname(i18nTsFilePath)
});
const mergedMessageBundle: { [languageTag: string]: Record<string, string> } =
Object.fromEntries(
Object.entries(baseMessageBundle).map(([languageTag, messages]) => [
languageTag,
{
...messages,
...(messageBundle === undefined
? {}
: messageBundle[languageTag] ??
messageBundle[FALLBACK_LANGUAGE_TAG] ??
messageBundle[Object.keys(messageBundle)[0]] ??
{})
}
])
const root = recastParseTs(filePath);
let declarationCode: string | undefined = "";
recast.visit(root, {
visitVariableDeclarator: function (path) {
const node = path.node;
// Check if the variable name is 'messages'
if (
node.id.type === "Identifier" &&
node.id.name === "messages"
) {
// Ensure there is an initializer
if (node.init) {
// Generate code from the initializer, preserving comments
declarationCode = recast
.print(node.init)
.code.replace(/}.*$/, "}");
}
return false; // Stop traversing this path
}
this.traverse(path); // Continue traversing other paths
}
});
assert(
declarationCode !== undefined,
`${filePath} does not contain a 'messages' variable declaration`
);
let messages: Record<string, string> = {};
try {
eval(`${symToStr({ messages })} = ${declarationCode};`);
} catch {
throw new Error(
`The declaration of 'message' in ${filePath} cannot be statically evaluated: ${declarationCode}`
);
}
return messages;
})()
]
)
);
const messageProperties: { languageTag: string; propertiesFileSource: string }[] =
Object.entries(mergedMessageBundle).map(([languageTag, messages]) => ({
languageTag,
propertiesFileSource: [
"",
...(themeType !== "account" ? ["parent=base"] : []),
...Object.entries(messages).map(
([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`
),
""
].join("\n")
}));
return messages_defaultSet_by_languageTag_notInDefaultSet;
})();
return messageProperties;
const messages_defaultSet_by_languageTag = {
...messages_defaultSet_by_languageTag_defaultSet,
...messages_defaultSet_by_languageTag_notInDefaultSet
};
const messages_themeDefined_by_languageTag:
| {
[languageTag: string]:
| Record<string, string | Record<string, string>>
| undefined;
}
| undefined = (() => {
if (i18nTsRoot === undefined) {
return undefined;
}
let firstArgumentCode: string | undefined = undefined;
recast.visit(i18nTsRoot, {
visitCallExpression: function (path) {
const node = path.node;
if (
node.callee.type === "MemberExpression" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "withCustomTranslations"
) {
firstArgumentCode = babelGenerate(node.arguments[0] as any).code;
return false;
}
this.traverse(path);
}
});
if (firstArgumentCode === undefined) {
return undefined;
}
let messages_themeDefined_by_languageTag: {
[languageTag: string]: Record<string, string | Record<string, string>>;
} = {};
try {
eval(
`${symToStr({ messages_themeDefined_by_languageTag })} = ${firstArgumentCode}`
);
} catch {
console.warn(
[
"WARNING: The argument of withCustomTranslations can't be statically evaluated!",
"This needs to be fixed refer to the documentation: https://docs.keycloakify.dev/i18n",
firstArgumentCode
].join(" ")
);
return undefined;
}
return messages_themeDefined_by_languageTag;
})();
const languageTags = Object.keys(messages_defaultSet_by_languageTag);
return {
languageTags,
writeMessagePropertiesFiles: ({ messageDirPath, themeName }) => {
for (const languageTag of languageTags) {
const messages = {
...messages_defaultSet_by_languageTag[languageTag]
};
add_theme_defined_messages: {
if (messages_themeDefined_by_languageTag === undefined) {
break add_theme_defined_messages;
}
let messages_themeDefined =
messages_themeDefined_by_languageTag[languageTag];
if (messages_themeDefined === undefined) {
messages_themeDefined =
messages_themeDefined_by_languageTag[FALLBACK_LANGUAGE_TAG];
}
if (messages_themeDefined === undefined) {
messages_themeDefined =
messages_themeDefined_by_languageTag[
Object.keys(messages_themeDefined_by_languageTag)[0]
];
}
if (messages_themeDefined === undefined) {
break add_theme_defined_messages;
}
for (const [key, messageOrMessageByThemeName] of Object.entries(
messages_themeDefined
)) {
const message = (() => {
if (typeof messageOrMessageByThemeName === "string") {
return messageOrMessageByThemeName;
}
const message = messageOrMessageByThemeName[themeName];
assert(message !== undefined);
return message;
})();
messages[key] = message;
}
}
const propertiesFileSource = [
"",
...Object.entries(messages).map(
([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`
),
""
].join("\n");
fs.mkdirSync(messageDirPath, { recursive: true });
fs.writeFileSync(
pathJoin(messageDirPath, `messages_${languageTag}.properties`),
Buffer.from(propertiesFileSource, "utf8")
);
}
}
};
}
function recastParseTs(filePath: string): recast.types.ASTNode {
return recast.parse(fs.readFileSync(filePath).toString("utf8"), {
parser: {
parse: (code: string) =>
babelParser.parse(code, {
sourceType: "module",
plugins: ["typescript"]
}),
generator: babelGenerate,
types: babelTypes
}
});
}

View File

@ -1,16 +1,56 @@
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import {
generateResourcesForMainTheme,
type BuildContextLike as BuildContextLike_generateResourcesForMainTheme
} from "./generateResourcesForMainTheme";
import { generateResourcesForThemeVariant } from "./generateResourcesForThemeVariant";
import fs from "fs";
import { rmSync } from "../../tools/fs.rmSync";
import { transformCodebase } from "../../tools/transformCodebase";
import {
join as pathJoin,
relative as pathRelative,
dirname as pathDirname,
extname as pathExtname,
sep as pathSep
} from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import {
generateFtlFilesCodeFactory,
type BuildContextLike as BuildContextLike_kcContextExclusionsFtlCode
} from "../generateFtl";
import {
type ThemeType,
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS,
WELL_KNOWN_DIRECTORY_BASE_NAME
} from "../../shared/constants";
import { assert, type Equals } from "tsafe/assert";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import {
generateMessageProperties,
type BuildContextLike as BuildContextLike_generateMessageProperties
} from "./generateMessageProperties";
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
import {
writeMetaInfKeycloakThemes,
type MetaInfKeycloakTheme
} from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import * as child_process from "child_process";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
import propertiesParser from "properties-parser";
export type BuildContextLike = BuildContextLike_generateResourcesForMainTheme & {
themeNames: string[];
};
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
BuildContextLike_generateMessageProperties & {
themeNames: string[];
extraThemeProperties: string[] | undefined;
projectDirPath: string;
projectBuildDirPath: string;
environmentVariables: { name: string; default: string }[];
implementedThemeTypes: BuildContext["implementedThemeTypes"];
themeSrcDirPath: string;
bundler: "vite" | "webpack";
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
@ -20,23 +60,443 @@ export async function generateResources(params: {
}): Promise<void> {
const { resourcesDirPath, buildContext } = params;
const [themeName, ...themeVariantNames] = buildContext.themeNames;
const [themeName] = buildContext.themeNames;
if (fs.existsSync(resourcesDirPath)) {
rmSync(resourcesDirPath, { recursive: true });
}
await generateResourcesForMainTheme({
resourcesDirPath,
themeName,
buildContext
});
const getThemeTypeDirPath = (params: {
themeType: ThemeType | "email";
themeName: string;
}) => {
const { themeType, themeName } = params;
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
};
for (const themeVariantName of themeVariantNames) {
generateResourcesForThemeVariant({
resourcesDirPath,
const writeMessagePropertiesFilesByThemeType: Partial<
Record<ThemeType, (params: { messageDirPath: string; themeName: string }) => void>
> = {};
for (const themeType of ["login", "account"] as const) {
if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
continue;
}
const isForAccountSpa =
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page");
const themeTypeDirPath = getThemeTypeDirPath({ themeName, themeType });
apply_replacers_and_move_to_theme_resources: {
const destDirPath = pathJoin(
themeTypeDirPath,
"resources",
WELL_KNOWN_DIRECTORY_BASE_NAME.DIST
);
// 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" &&
buildContext.implementedThemeTypes.login.isImplemented
) {
// NOTE: We prevent doing it twice, it has been done for the login theme.
transformCodebase({
srcDirPath: pathJoin(
getThemeTypeDirPath({
themeName,
themeType: "login"
}),
"resources",
WELL_KNOWN_DIRECTORY_BASE_NAME.DIST
),
destDirPath
});
break apply_replacers_and_move_to_theme_resources;
}
{
const dirPath = pathJoin(
buildContext.projectBuildDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
);
if (fs.existsSync(dirPath)) {
assert(buildContext.bundler === "webpack");
throw new Error(
[
`Keycloakify build error: The ${WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES} directory shouldn't exist in your build directory.`,
`(${pathRelative(process.cwd(), dirPath)}).\n`,
`Theses assets are only required for local development with Storybook.",
"Please remove this directory as an additional step of your command.\n`,
`For example: \`"build": "... && rimraf ${pathRelative(buildContext.projectDirPath, dirPath)}"\``
].join(" ")
);
}
}
transformCodebase({
srcDirPath: buildContext.projectBuildDirPath,
destDirPath,
transformSourceCode: ({ filePath, fileRelativePath, sourceCode }) => {
if (filePath.endsWith(".css")) {
const { fixedCssCode } = replaceImportsInCssCode({
cssCode: sourceCode.toString("utf8"),
cssFileRelativeDirPath: pathDirname(fileRelativePath),
buildContext
});
return {
modifiedSourceCode: Buffer.from(fixedCssCode, "utf8")
};
}
if (filePath.endsWith(".js")) {
const { fixedJsCode } = replaceImportsInJsCode({
jsCode: sourceCode.toString("utf8"),
buildContext
});
return {
modifiedSourceCode: Buffer.from(fixedJsCode, "utf8")
};
}
return { modifiedSourceCode: sourceCode };
}
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
themeVariantName
indexHtmlCode: fs
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
.toString("utf8"),
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
})
});
[
...(() => {
switch (themeType) {
case "login":
return LOGIN_THEME_PAGE_IDS;
case "account":
return isForAccountSpa ? ["index.ftl"] : ACCOUNT_THEME_PAGE_IDS;
}
})(),
...(isForAccountSpa
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
);
});
let languageTags: string[] | undefined = undefined;
i18n_messages_generation: {
if (isForAccountSpa) {
break i18n_messages_generation;
}
const wrap = generateMessageProperties({
buildContext,
themeType
});
languageTags = wrap.languageTags;
const { writeMessagePropertiesFiles } = wrap;
writeMessagePropertiesFilesByThemeType[themeType] =
writeMessagePropertiesFiles;
}
bring_in_account_v3_i18n_messages: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v3_i18n_messages;
}
if (buildContext.implementedThemeTypes.account.type !== "Single-Page") {
break bring_in_account_v3_i18n_messages;
}
const accountUiDirPath = child_process
.execSync("npm list @keycloakify/keycloak-account-ui --parseable", {
cwd: pathDirname(buildContext.packageJsonFilePath)
})
.toString("utf8")
.trim();
const messageDirPath_defaults = pathJoin(accountUiDirPath, "messages");
if (!fs.existsSync(messageDirPath_defaults)) {
throw new Error(
`Please update @keycloakify/keycloak-account-ui to 25.0.4-rc.5 or later.`
);
}
const messagesDirPath_dest = pathJoin(
getThemeTypeDirPath({ themeName, themeType: "account" }),
"messages"
);
transformCodebase({
srcDirPath: messageDirPath_defaults,
destDirPath: messagesDirPath_dest
});
apply_theme_changes: {
const messagesDirPath_theme = pathJoin(
buildContext.themeSrcDirPath,
"account",
"messages"
);
if (!fs.existsSync(messagesDirPath_theme)) {
break apply_theme_changes;
}
fs.readdirSync(messagesDirPath_theme).forEach(basename => {
const filePath_src = pathJoin(messagesDirPath_theme, basename);
const filePath_dest = pathJoin(messagesDirPath_dest, basename);
if (!fs.existsSync(filePath_dest)) {
fs.cpSync(filePath_src, filePath_dest);
}
const messages_src = propertiesParser.parse(
fs.readFileSync(filePath_src).toString("utf8")
);
const messages_dest = propertiesParser.parse(
fs.readFileSync(filePath_dest).toString("utf8")
);
const messages = {
...messages_dest,
...messages_src
};
const editor = propertiesParser.createEditor();
Object.entries(messages).forEach(([key, value]) => {
editor.set(key, value);
});
fs.writeFileSync(
filePath_dest,
Buffer.from(editor.toString(), "utf8")
);
});
}
languageTags = fs
.readdirSync(messagesDirPath_dest)
.map(basename =>
basename.replace(/^messages_/, "").replace(/\.properties$/, "")
);
}
keycloak_static_resources: {
if (isForAccountSpa) {
break keycloak_static_resources;
}
transformCodebase({
srcDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES,
themeType
),
destDirPath: pathJoin(themeTypeDirPath, "resources")
});
}
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
return isForAccountSpa ? "base" : "account-v1";
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(isForAccountSpa ? ["deprecatedMode=false"] : []),
...(buildContext.extraThemeProperties ?? []),
...buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
),
...(languageTags === undefined
? []
: [`locales=${languageTags.join(",")}`])
].join("\n\n"),
"utf8"
)
);
}
email: {
if (!buildContext.implementedThemeTypes.email.isImplemented) {
break email;
}
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
transformCodebase({
srcDirPath: emailThemeSrcDirPath,
destDirPath: getThemeTypeDirPath({ themeName, themeType: "email" })
});
}
bring_in_account_v1: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v1;
}
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
break bring_in_account_v1;
}
transformCodebase({
srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
destDirPath: getThemeTypeDirPath({
themeName: "account-v1",
themeType: "account"
})
});
}
{
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
for (const themeName of buildContext.themeNames) {
metaInfKeycloakThemes.themes.push({
name: themeName,
types: objectEntries(buildContext.implementedThemeTypes)
.filter(([, { isImplemented }]) => isImplemented)
.map(([themeType]) => themeType)
});
}
if (buildContext.implementedThemeTypes.account.isImplemented) {
metaInfKeycloakThemes.themes.push({
name: "account-v1",
types: ["account"]
});
}
writeMetaInfKeycloakThemes({
resourcesDirPath,
getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes
});
}
for (const themeVariantName of buildContext.themeNames) {
if (themeVariantName === themeName) {
continue;
}
transformCodebase({
srcDirPath: pathJoin(resourcesDirPath, "theme", themeName),
destDirPath: pathJoin(resourcesDirPath, "theme", themeVariantName),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (
pathExtname(fileRelativePath) === ".ftl" &&
fileRelativePath.split(pathSep).length === 2
) {
const modifiedSourceCode = Buffer.from(
Buffer.from(sourceCode)
.toString("utf-8")
.replace(
`"themeName": "${themeName}"`,
`"themeName": "${themeVariantName}"`
),
"utf8"
);
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
return { modifiedSourceCode };
}
return { modifiedSourceCode: sourceCode };
}
});
}
for (const themeName of buildContext.themeNames) {
for (const [themeType, writeMessagePropertiesFiles] of objectEntries(
writeMessagePropertiesFilesByThemeType
)) {
// NOTE: This is just a quirk of the type system: We can't really differentiate in a record
// between the case where the key isn't present and the case where the value is `undefined`.
if (writeMessagePropertiesFiles === undefined) {
return;
}
writeMessagePropertiesFiles({
messageDirPath: pathJoin(
getThemeTypeDirPath({ themeName, themeType }),
"messages"
),
themeName
});
}
}
modify_email_theme_per_variant: {
if (!buildContext.implementedThemeTypes.email.isImplemented) {
break modify_email_theme_per_variant;
}
for (const themeName of buildContext.themeNames) {
const emailThemeDirPath = getThemeTypeDirPath({
themeName,
themeType: "email"
});
transformCodebase({
srcDirPath: emailThemeDirPath,
destDirPath: emailThemeDirPath,
transformSourceCode: ({ filePath, sourceCode }) => {
if (!filePath.endsWith(".ftl")) {
return { modifiedSourceCode: sourceCode };
}
return {
modifiedSourceCode: Buffer.from(
sourceCode
.toString("utf8")
.replace(/xKeycloakify\.themeName/g, `"${themeName}"`),
"utf8"
)
};
}
});
}
}
}

View File

@ -1,341 +0,0 @@
import { transformCodebase } from "../../tools/transformCodebase";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
import {
generateFtlFilesCodeFactory,
type BuildContextLike as BuildContextLike_kcContextExclusionsFtlCode
} from "../generateFtl";
import {
type ThemeType,
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS,
WELL_KNOWN_DIRECTORY_BASE_NAME
} from "../../shared/constants";
import type { BuildContext } from "../../shared/buildContext";
import { assert, type Equals } from "tsafe/assert";
import { readFieldNameUsage } from "./readFieldNameUsage";
import { readExtraPagesNames } from "./readExtraPageNames";
import { generateMessageProperties } from "./generateMessageProperties";
import { rmSync } from "../../tools/fs.rmSync";
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
import {
writeMetaInfKeycloakThemes,
type MetaInfKeycloakTheme
} from "../../shared/metaInfKeycloakThemes";
import { objectEntries } from "tsafe/objectEntries";
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
import * as child_process from "child_process";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode & {
extraThemeProperties: string[] | undefined;
projectDirPath: string;
projectBuildDirPath: string;
environmentVariables: { name: string; default: string }[];
implementedThemeTypes: BuildContext["implementedThemeTypes"];
themeSrcDirPath: string;
bundler: "vite" | "webpack";
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateResourcesForMainTheme(params: {
themeName: string;
resourcesDirPath: string;
buildContext: BuildContextLike;
}): Promise<void> {
const { themeName, resourcesDirPath, buildContext } = params;
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => {
const { themeType } = params;
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
};
for (const themeType of ["login", "account"] as const) {
if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
continue;
}
const isForAccountSpa =
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page");
const themeTypeDirPath = getThemeTypeDirPath({ themeType });
apply_replacers_and_move_to_theme_resources: {
const destDirPath = pathJoin(
themeTypeDirPath,
"resources",
WELL_KNOWN_DIRECTORY_BASE_NAME.DIST
);
// 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" &&
buildContext.implementedThemeTypes.login.isImplemented
) {
// NOTE: We prevent doing it twice, it has been done for the login theme.
transformCodebase({
srcDirPath: pathJoin(
getThemeTypeDirPath({
themeType: "login"
}),
"resources",
WELL_KNOWN_DIRECTORY_BASE_NAME.DIST
),
destDirPath
});
break apply_replacers_and_move_to_theme_resources;
}
{
const dirPath = pathJoin(
buildContext.projectBuildDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY
);
if (fs.existsSync(dirPath)) {
assert(buildContext.bundler === "webpack");
throw new Error(
[
`Keycloakify build error: The ${WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY} directory shouldn't exist in your build directory.`,
`(${pathRelative(process.cwd(), dirPath)}).\n`,
`Theses assets are only required for local development with Storybook.",
"Please remove this directory as an additional step of your command.\n`,
`For example: \`"build": "... && rimraf ${pathRelative(buildContext.projectDirPath, dirPath)}"\``
].join(" ")
);
}
}
transformCodebase({
srcDirPath: buildContext.projectBuildDirPath,
destDirPath,
transformSourceCode: ({ filePath, fileRelativePath, sourceCode }) => {
if (filePath.endsWith(".css")) {
const { fixedCssCode } = replaceImportsInCssCode({
cssCode: sourceCode.toString("utf8"),
cssFileRelativeDirPath: pathDirname(fileRelativePath),
buildContext
});
return {
modifiedSourceCode: Buffer.from(fixedCssCode, "utf8")
};
}
if (filePath.endsWith(".js")) {
const { fixedJsCode } = replaceImportsInJsCode({
jsCode: sourceCode.toString("utf8"),
buildContext
});
return {
modifiedSourceCode: Buffer.from(fixedJsCode, "utf8")
};
}
return { modifiedSourceCode: sourceCode };
}
});
}
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
themeName,
indexHtmlCode: fs
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
.toString("utf8"),
buildContext,
keycloakifyVersion: readThisNpmPackageVersion(),
themeType,
fieldNames: readFieldNameUsage({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
})
});
[
...(() => {
switch (themeType) {
case "login":
return LOGIN_THEME_PAGE_IDS;
case "account":
return isForAccountSpa ? ["index.ftl"] : ACCOUNT_THEME_PAGE_IDS;
}
})(),
...(isForAccountSpa
? []
: readExtraPagesNames({
themeType,
themeSrcDirPath: buildContext.themeSrcDirPath
}))
].forEach(pageId => {
const { ftlCode } = generateFtlFilesCode({ pageId });
fs.writeFileSync(
pathJoin(themeTypeDirPath, pageId),
Buffer.from(ftlCode, "utf8")
);
});
i18n_messages_generation: {
if (isForAccountSpa) {
break i18n_messages_generation;
}
generateMessageProperties({
themeSrcDirPath: buildContext.themeSrcDirPath,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), {
recursive: true
});
const propertiesFilePath = pathJoin(
messagesDirPath,
`messages_${languageTag}.properties`
);
fs.writeFileSync(
propertiesFilePath,
Buffer.from(propertiesFileSource, "utf8")
);
});
}
bring_in_account_v3_i18n_messages: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v3_i18n_messages;
}
if (buildContext.implementedThemeTypes.account.type !== "Single-Page") {
break bring_in_account_v3_i18n_messages;
}
const accountUiDirPath = child_process
.execSync("npm list @keycloakify/keycloak-account-ui --parseable", {
cwd: pathDirname(buildContext.packageJsonFilePath)
})
.toString("utf8")
.trim();
const messagesDirPath = pathJoin(accountUiDirPath, "messages");
if (!fs.existsSync(messagesDirPath)) {
throw new Error(
`Please update @keycloakify/keycloak-account-ui to 25.0.4-rc.5 or later.`
);
}
transformCodebase({
srcDirPath: messagesDirPath,
destDirPath: pathJoin(
getThemeTypeDirPath({ themeType: "account" }),
"messages"
)
});
}
keycloak_static_resources: {
if (isForAccountSpa) {
break keycloak_static_resources;
}
transformCodebase({
srcDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY,
themeType
),
destDirPath: pathJoin(themeTypeDirPath, "resources")
});
}
fs.writeFileSync(
pathJoin(themeTypeDirPath, "theme.properties"),
Buffer.from(
[
`parent=${(() => {
switch (themeType) {
case "account":
return isForAccountSpa ? "base" : "account-v1";
case "login":
return "keycloak";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(isForAccountSpa ? ["deprecatedMode=false"] : []),
...(buildContext.extraThemeProperties ?? []),
...buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
)
].join("\n\n"),
"utf8"
)
);
}
email: {
if (!buildContext.implementedThemeTypes.email.isImplemented) {
break email;
}
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
transformCodebase({
srcDirPath: emailThemeSrcDirPath,
destDirPath: getThemeTypeDirPath({ themeType: "email" })
});
}
bring_in_account_v1: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v1;
}
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
break bring_in_account_v1;
}
transformCodebase({
srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
destDirPath: pathJoin(resourcesDirPath, "theme", "account-v1", "account")
});
}
{
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
metaInfKeycloakThemes.themes.push({
name: themeName,
types: objectEntries(buildContext.implementedThemeTypes)
.filter(([, { isImplemented }]) => isImplemented)
.map(([themeType]) => themeType)
});
if (buildContext.implementedThemeTypes.account.isImplemented) {
metaInfKeycloakThemes.themes.push({
name: "account-v1",
types: ["account"]
});
}
writeMetaInfKeycloakThemes({
resourcesDirPath,
getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes
});
}
}

View File

@ -1,70 +0,0 @@
import { join as pathJoin, extname as pathExtname, sep as pathSep } from "path";
import { transformCodebase } from "../../tools/transformCodebase";
import type { BuildContext } from "../../shared/buildContext";
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
import { assert } from "tsafe/assert";
export type BuildContextLike = {
keycloakifyBuildDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function generateResourcesForThemeVariant(params: {
resourcesDirPath: string;
themeName: string;
themeVariantName: string;
}) {
const { resourcesDirPath, themeName, themeVariantName } = params;
const mainThemeDirPath = pathJoin(resourcesDirPath, "theme", themeName);
transformCodebase({
srcDirPath: mainThemeDirPath,
destDirPath: pathJoin(mainThemeDirPath, "..", themeVariantName),
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (
pathExtname(fileRelativePath) === ".ftl" &&
fileRelativePath.split(pathSep).length === 2
) {
const modifiedSourceCode = Buffer.from(
Buffer.from(sourceCode)
.toString("utf-8")
.replace(
`"themeName": "${themeName}"`,
`"themeName": "${themeVariantName}"`
),
"utf8"
);
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
return { modifiedSourceCode };
}
return { modifiedSourceCode: sourceCode };
}
});
writeMetaInfKeycloakThemes({
resourcesDirPath,
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => {
assert(metaInfKeycloakTheme !== undefined);
const newMetaInfKeycloakTheme = metaInfKeycloakTheme;
newMetaInfKeycloakTheme.themes.push({
name: themeVariantName,
types: (() => {
const theme = newMetaInfKeycloakTheme.themes.find(
({ name }) => name === themeName
);
assert(theme !== undefined);
return theme.types;
})()
});
return newMetaInfKeycloakTheme;
}
});
}

View File

@ -2,19 +2,16 @@ import { generateResources } from "./generateResources";
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
import * as child_process from "child_process";
import * as fs from "fs";
import { getBuildContext } from "../shared/buildContext";
import type { BuildContext } from "../shared/buildContext";
import { VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES } from "../shared/constants";
import { buildJars } from "./buildJars";
import type { CliCommandOptions } from "../main";
import chalk from "chalk";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import * as os from "os";
import { rmSync } from "../tools/fs.rmSync";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildContext = getBuildContext({ cliCommandOptions });
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
exit_if_maven_not_installed: {
let commandOutput: Buffer | undefined = undefined;

View File

@ -4,8 +4,9 @@ import { termost } from "termost";
import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
import * as child_process from "child_process";
import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx";
import { getBuildContext } from "./shared/buildContext";
export type CliCommandOptions = {
type CliCommandOptions = {
projectDirPath: string | undefined;
};
@ -69,10 +70,10 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./keycloakify");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
@ -130,10 +131,13 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath, keycloakVersion, port, realmJsonFilePath }) => {
const { command } = await import("./start-keycloak");
await command({ cliCommandOptions });
await command({
buildContext: getBuildContext({ projectDirPath }),
cliCommandOptions: { keycloakVersion, port, realmJsonFilePath }
});
}
});
@ -144,10 +148,10 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./eject-page");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
@ -158,10 +162,10 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./add-story");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
@ -172,10 +176,10 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./initialize-email-theme");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
@ -186,10 +190,10 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./initialize-account-theme");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
@ -201,10 +205,10 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./copy-keycloak-resources-to-public");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
@ -216,10 +220,10 @@ program
})
.task({
skip,
handler: async cliCommandOptions => {
handler: async ({ projectDirPath }) => {
const { command } = await import("./update-kc-gen");
await command({ cliCommandOptions });
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});

View File

@ -3,7 +3,7 @@ export type KeycloakVersionRange =
| KeycloakVersionRange.WithoutAccountV1Theme;
export namespace KeycloakVersionRange {
export type WithoutAccountV1Theme = "21-and-below" | "22-and-above";
export type WithoutAccountV1Theme = "22-to-25" | "all-other-versions";
export type WithAccountV1Theme = "21-and-below" | "23" | "24" | "25-and-above";
export type WithAccountV1Theme = "21-and-below" | "23" | "24" | "25" | "26-and-above";
}

View File

@ -7,7 +7,6 @@ import {
dirname as pathDirname
} from "path";
import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath";
import type { CliCommandOptions } from "../main";
import { z } from "zod";
import * as fs from "fs";
import { assert, type Equals } from "tsafe/assert";
@ -24,7 +23,8 @@ import { objectEntries } from "tsafe/objectEntries";
import { type ThemeType } from "./constants";
import { id } from "tsafe/id";
import chalk from "chalk";
import { getProxyFetchOptions, type ProxyFetchOptions } from "../tools/fetchProxyOptions";
import { getProxyFetchOptions, type FetchOptionsLike } from "../tools/fetchProxyOptions";
import { is } from "tsafe/is";
export type BuildContext = {
themeVersion: string;
@ -42,7 +42,7 @@ export type BuildContext = {
* In this case the urlPathname will be "/my-app/" */
urlPathname: string | undefined;
assetsDirPath: string;
fetchOptions: ProxyFetchOptions;
fetchOptions: FetchOptionsLike;
kcContextExclusionsFtlCode: string | undefined;
environmentVariables: { name: string; default: string }[];
themeSrcDirPath: string;
@ -128,14 +128,12 @@ export type ResolvedViteConfig = {
};
export function getBuildContext(params: {
cliCommandOptions: CliCommandOptions;
projectDirPath: string | undefined;
}): BuildContext {
const { cliCommandOptions } = params;
const projectDirPath =
cliCommandOptions.projectDirPath !== undefined
params.projectDirPath !== undefined
? getAbsoluteAndInOsFormatPath({
pathIsh: cliCommandOptions.projectDirPath,
pathIsh: params.projectDirPath,
cwd: process.cwd()
})
: process.cwd();
@ -240,8 +238,7 @@ export function getBuildContext(params: {
if (
parsedPackageJson.dependencies?.keycloakify === undefined &&
parsedPackageJson.devDependencies?.keycloakify === undefined &&
parsedPackageJson.name !== "keycloakify" // NOTE: For local storybook build
parsedPackageJson.devDependencies?.keycloakify === undefined
) {
break success;
}
@ -277,7 +274,8 @@ export function getBuildContext(params: {
"21-and-below": z.union([z.boolean(), z.string()]),
"23": z.union([z.boolean(), z.string()]),
"24": z.union([z.boolean(), z.string()]),
"25-and-above": z.union([z.boolean(), z.string()])
"25": z.union([z.boolean(), z.string()]),
"26-and-above": z.union([z.boolean(), z.string()])
})
.optional()
});
@ -298,8 +296,8 @@ export function getBuildContext(params: {
]),
keycloakVersionTargets: z
.object({
"21-and-below": z.union([z.boolean(), z.string()]),
"22-and-above": z.union([z.boolean(), z.string()])
"22-to-25": z.union([z.boolean(), z.string()]),
"all-other-versions": z.union([z.boolean(), z.string()])
})
.optional()
});
@ -470,26 +468,53 @@ export function getBuildContext(params: {
}
const themeNames = ((): [string, ...string[]] => {
if (buildOptions.themeName === undefined) {
return parsedPackageJson.name === undefined
? ["keycloakify"]
: [
parsedPackageJson.name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-")
];
const themeNames = ((): [string, ...string[]] => {
if (buildOptions.themeName === undefined) {
return parsedPackageJson.name === undefined
? ["keycloakify"]
: [
parsedPackageJson.name
.replace(/^@(.*)/, "$1")
.split("/")
.join("-")
];
}
if (typeof buildOptions.themeName === "string") {
return [buildOptions.themeName];
}
const [mainThemeName, ...themeVariantNames] = buildOptions.themeName;
assert(mainThemeName !== undefined);
return [mainThemeName, ...themeVariantNames];
})();
for (const themeName of themeNames) {
if (!/^[a-zA-Z0-9_-]+$/.test(themeName)) {
console.error(
chalk.red(
[
`Invalid theme name: ${themeName}`,
`Theme names should only contain letters, numbers, and "_" or "-"`
].join(" ")
)
);
process.exit(-1);
}
}
if (typeof buildOptions.themeName === "string") {
return [buildOptions.themeName];
return themeNames;
})();
const relativePathsCwd = (() => {
switch (bundler) {
case "vite":
return projectDirPath;
case "webpack":
return pathDirname(packageJsonFilePath);
}
const [mainThemeName, ...themeVariantNames] = buildOptions.themeName;
assert(mainThemeName !== undefined);
return [mainThemeName, ...themeVariantNames];
})();
const projectBuildDirPath = (() => {
@ -503,7 +528,7 @@ export function getBuildContext(params: {
if (parsedPackageJson.keycloakify.projectBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: parsedPackageJson.keycloakify.projectBuildDirPath,
cwd: projectDirPath
cwd: relativePathsCwd
});
}
@ -547,7 +572,7 @@ export function getBuildContext(params: {
if (buildOptions.keycloakifyBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.keycloakifyBuildDirPath,
cwd: projectDirPath
cwd: relativePathsCwd
});
}
@ -576,7 +601,7 @@ export function getBuildContext(params: {
if (parsedPackageJson.keycloakify.publicDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: parsedPackageJson.keycloakify.publicDirPath,
cwd: projectDirPath
cwd: relativePathsCwd
});
}
@ -648,7 +673,7 @@ export function getBuildContext(params: {
pathIsh:
parsedPackageJson.keycloakify
.staticDirPathInProjectBuildDirPath,
cwd: projectBuildDirPath
cwd: relativePathsCwd
});
}
@ -749,7 +774,11 @@ export function getBuildContext(params: {
return "24" as const;
}
return "25-and-above" as const;
if (buildForKeycloakMajorVersionNumber === 25) {
return "25" as const;
}
return "26-and-above" as const;
})();
assert<
@ -762,11 +791,14 @@ export function getBuildContext(params: {
return keycloakVersionRange;
} else {
const keycloakVersionRange = (() => {
if (buildForKeycloakMajorVersionNumber <= 21) {
return "21-and-below" as const;
if (
buildForKeycloakMajorVersionNumber <= 21 ||
buildForKeycloakMajorVersionNumber >= 26
) {
return "all-other-versions" as const;
}
return "22-and-above" as const;
return "22-to-25" as const;
})();
assert<
@ -784,6 +816,12 @@ export function getBuildContext(params: {
use_custom_jar_basename: {
const { keycloakVersionTargets } = buildOptions;
assert(
is<Record<KeycloakVersionRange, string | boolean>>(
keycloakVersionTargets
)
);
if (keycloakVersionTargets === undefined) {
break use_custom_jar_basename;
}
@ -828,7 +866,8 @@ export function getBuildContext(params: {
"21-and-below",
"23",
"24",
"25-and-above"
"25",
"26-and-above"
] as const) {
assert<
Equals<
@ -844,8 +883,8 @@ export function getBuildContext(params: {
}
} else {
for (const keycloakVersionRange of [
"21-and-below",
"22-and-above"
"22-to-25",
"all-other-versions"
] as const) {
assert<
Equals<
@ -871,7 +910,17 @@ export function getBuildContext(params: {
const jarTargets: BuildContext["jarTargets"] = [];
for (const [keycloakVersionRange, jarNameOrBoolean] of objectEntries(
buildOptions.keycloakVersionTargets
(() => {
const { keycloakVersionTargets } = buildOptions;
assert(
is<Record<KeycloakVersionRange, string | boolean>>(
keycloakVersionTargets
)
);
return keycloakVersionTargets;
})()
)) {
if (jarNameOrBoolean === false) {
continue;
@ -952,7 +1001,7 @@ export function getBuildContext(params: {
type: "path",
path: getAbsoluteAndInOsFormatPath({
pathIsh: urlOrPath,
cwd: projectDirPath
cwd: relativePathsCwd
})
};
}
@ -962,7 +1011,7 @@ export function getBuildContext(params: {
? undefined
: getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.startKeycloakOptions.realmJsonFilePath,
cwd: projectDirPath
cwd: relativePathsCwd
}),
port: buildOptions.startKeycloakOptions?.port
}

View File

@ -1,5 +1,5 @@
export const WELL_KNOWN_DIRECTORY_BASE_NAME = {
DOT_KEYCLOAKIFY: ".keycloakify",
KEYCLOAKIFY_DEV_RESOURCES: "keycloakify-dev-resources",
RESOURCES_COMMON: "resources-common",
DIST: "dist"
} as const;
@ -71,3 +71,8 @@ export type AccountThemePageId = (typeof ACCOUNT_THEME_PAGE_IDS)[number];
export const CONTAINER_NAME = "keycloak-keycloakify";
export const FALLBACK_LANGUAGE_TAG = "en";
export const CUSTOM_HANDLER_ENV_NAMES = {
COMMAND_NAME: "KEYCLOAKIFY_COMMAND_NAME",
BUILD_CONTEXT: "KEYCLOAKIFY_BUILD_CONTEXT"
};

View File

@ -1,91 +0,0 @@
import { join as pathJoin, dirname as pathDirname } from "path";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../shared/constants";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import { assert } from "tsafe/assert";
import * as fs from "fs";
import { rmSync } from "../tools/fs.rmSync";
import type { BuildContext } from "./buildContext";
import { transformCodebase } from "../tools/transformCodebase";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
export type BuildContextLike = {
publicDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function copyKeycloakResourcesToPublic(params: {
buildContext: BuildContextLike;
}) {
const { buildContext } = params;
const destDirPath = pathJoin(
buildContext.publicDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY
);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
const keycloakifyBuildinfoRaw = JSON.stringify(
{
keycloakifyVersion: readThisNpmPackageVersion()
},
null,
2
);
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;
}
return;
}
rmSync(destDirPath, { force: true, recursive: true });
// NOTE: To remove in a while, remove the legacy keycloak-resources directory
rmSync(pathJoin(pathDirname(destDirPath), "keycloak-resources"), {
force: true,
recursive: true
});
fs.mkdirSync(destDirPath, { recursive: true });
fs.writeFileSync(pathJoin(destDirPath, ".gitignore"), Buffer.from("*", "utf8"));
transformCodebase({
srcDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY
),
destDirPath
});
fs.writeFileSync(
pathJoin(destDirPath, "README.txt"),
Buffer.from(
// prettier-ignore
[
"This directory is only used in dev mode by Keycloakify",
"It won't be included in your final build.",
"Do not modify anything in this directory.",
].join("\n")
)
);
fs.writeFileSync(
keycloakifyBuildinfoFilePath,
Buffer.from(keycloakifyBuildinfoRaw, "utf8")
);
}

View File

@ -0,0 +1,41 @@
import { assert } from "tsafe/assert";
import type { BuildContext } from "./buildContext";
import { CUSTOM_HANDLER_ENV_NAMES } from "./constants";
export const BIN_NAME = "_keycloakify-custom-handler";
export const NOT_IMPLEMENTED_EXIT_CODE = 78;
export type CommandName =
| "update-kc-gen"
| "eject-page"
| "add-story"
| "initialize-account-theme"
| "initialize-email-theme"
| "copy-keycloak-resources-to-public";
export type ApiVersion = "v1";
export function readParams(params: { apiVersion: ApiVersion }) {
const { apiVersion } = params;
assert(apiVersion === "v1");
const commandName = (() => {
const envValue = process.env[CUSTOM_HANDLER_ENV_NAMES.COMMAND_NAME];
assert(envValue !== undefined);
return envValue as CommandName;
})();
const buildContext = (() => {
const envValue = process.env[CUSTOM_HANDLER_ENV_NAMES.BUILD_CONTEXT];
assert(envValue !== undefined);
return JSON.parse(envValue) as BuildContext;
})();
return { commandName, buildContext };
}

View File

@ -0,0 +1,73 @@
import { assert, type Equals } from "tsafe/assert";
import type { BuildContext } from "./buildContext";
import { CUSTOM_HANDLER_ENV_NAMES } from "./constants";
import {
NOT_IMPLEMENTED_EXIT_CODE,
type CommandName,
BIN_NAME,
ApiVersion
} from "./customHandler";
import * as child_process from "child_process";
import { sep as pathSep } from "path";
import * as fs from "fs";
assert<Equals<ApiVersion, "v1">>();
export function maybeDelegateCommandToCustomHandler(params: {
commandName: CommandName;
buildContext: BuildContext;
}): { hasBeenHandled: boolean } {
const { commandName, buildContext } = params;
const nodeModulesBinDirPath = (() => {
const binPath = process.argv[1];
const segments: string[] = [".bin"];
let foundNodeModules = false;
for (const segment of binPath.split(pathSep).reverse()) {
skip_segment: {
if (foundNodeModules) {
break skip_segment;
}
if (segment === "node_modules") {
foundNodeModules = true;
break skip_segment;
}
continue;
}
segments.unshift(segment);
}
return segments.join(pathSep);
})();
if (!fs.readdirSync(nodeModulesBinDirPath).includes(BIN_NAME)) {
return { hasBeenHandled: false };
}
try {
child_process.execSync(`npx ${BIN_NAME}`, {
stdio: "inherit",
env: {
...process.env,
[CUSTOM_HANDLER_ENV_NAMES.COMMAND_NAME]: commandName,
[CUSTOM_HANDLER_ENV_NAMES.BUILD_CONTEXT]: JSON.stringify(buildContext)
}
});
} catch (error: any) {
const status = error.status;
if (status === NOT_IMPLEMENTED_EXIT_CODE) {
return { hasBeenHandled: false };
}
process.exit(status);
}
return { hasBeenHandled: true };
}

View File

@ -1,175 +0,0 @@
import { assert, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
import type { BuildContext } from "./buildContext";
import * as fs from "fs/promises";
import { join as pathJoin } from "path";
import { existsAsync } from "../tools/fs.existsAsync";
import { z } from "zod";
export type BuildContextLike = {
projectDirPath: string;
themeNames: string[];
environmentVariables: { name: string; default: string }[];
themeSrcDirPath: string;
implementedThemeTypes: Pick<
BuildContext["implementedThemeTypes"],
"login" | "account"
>;
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateKcGenTs(params: {
buildContext: BuildContextLike;
}): Promise<void> {
const { buildContext } = params;
const isReactProject: boolean = await (async () => {
const parsedPackageJson = await (async () => {
type ParsedPackageJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
dependencies: z.record(z.string()).optional(),
devDependencies: z.record(z.string()).optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
return zParsedPackageJson.parse(
JSON.parse(
(await fs.readFile(buildContext.packageJsonFilePath)).toString("utf8")
)
);
})();
return (
{
...parsedPackageJson.dependencies,
...parsedPackageJson.devDependencies
}.react !== undefined
);
})();
const filePath = pathJoin(
buildContext.themeSrcDirPath,
`kc.gen.ts${isReactProject ? "x" : ""}`
);
const currentContent = (await existsAsync(filePath))
? await fs.readFile(filePath)
: undefined;
const hasLoginTheme = buildContext.implementedThemeTypes.login.isImplemented;
const hasAccountTheme = buildContext.implementedThemeTypes.account.isImplemented;
const newContent = Buffer.from(
[
`/* prettier-ignore-start */`,
``,
`/* eslint-disable */`,
``,
`// @ts-nocheck`,
``,
`// noinspection JSUnusedGlobalSymbols`,
``,
`// This file is auto-generated by Keycloakify`,
``,
isReactProject && `import { lazy, Suspense, type ReactNode } from "react";`,
``,
`export type ThemeName = ${buildContext.themeNames.map(themeName => `"${themeName}"`).join(" | ")};`,
``,
`export const themeNames: ThemeName[] = [${buildContext.themeNames.map(themeName => `"${themeName}"`).join(", ")}];`,
``,
`export type KcEnvName = ${buildContext.environmentVariables.length === 0 ? "never" : buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(" | ")};`,
``,
`export const kcEnvNames: KcEnvName[] = [${buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(", ")}];`,
``,
`export const kcEnvDefaults: Record<KcEnvName, string> = ${JSON.stringify(
Object.fromEntries(
buildContext.environmentVariables.map(
({ name, default: defaultValue }) => [name, defaultValue]
)
),
null,
2
)};`,
``,
`export type KcContext =`,
hasLoginTheme && ` | import("./login/KcContext").KcContext`,
hasAccountTheme && ` | import("./account/KcContext").KcContext`,
` ;`,
``,
`declare global {`,
` interface Window {`,
` kcContext?: KcContext;`,
` }`,
`}`,
``,
...(!isReactProject
? []
: [
hasLoginTheme &&
`export const KcLoginPage = lazy(() => import("./login/KcPage"));`,
hasAccountTheme &&
`export const KcAccountPage = lazy(() => import("./account/KcPage"));`,
``,
`export function KcPage(`,
` props: {`,
` kcContext: KcContext;`,
` fallback?: ReactNode;`,
` }`,
`) {`,
` const { kcContext, fallback } = props;`,
` return (`,
` <Suspense fallback={fallback}>`,
` {(() => {`,
` switch (kcContext.themeType) {`,
hasLoginTheme &&
` case "login": return <KcLoginPage kcContext={kcContext} />;`,
hasAccountTheme &&
` case "account": return <KcAccountPage kcContext={kcContext} />;`,
` }`,
` })()}`,
` </Suspense>`,
` );`,
`}`
]),
``,
`/* prettier-ignore-end */`,
``
]
.filter(item => typeof item === "string")
.join("\n"),
"utf8"
);
if (currentContent !== undefined && currentContent.equals(newContent)) {
return;
}
await fs.writeFile(filePath, newContent);
delete_legacy_file: {
if (!isReactProject) {
break delete_legacy_file;
}
const legacyFilePath = filePath.replace(/tsx$/, "ts");
if (!(await existsAsync(legacyFilePath))) {
break delete_legacy_file;
}
await fs.unlink(legacyFilePath);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
import { getBuildContext } from "../shared/buildContext";
import type { BuildContext } from "../shared/buildContext";
import { exclude } from "tsafe/exclude";
import type { CliCommandOptions as CliCommandOptions_common } from "../main";
import { promptKeycloakVersion } from "../shared/promptKeycloakVersion";
import { CONTAINER_NAME } from "../shared/constants";
import { SemVer } from "../tools/SemVer";
@ -29,23 +28,26 @@ import { existsAsync } from "../tools/fs.existsAsync";
import { rm } from "../tools/fs.rm";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
export type CliCommandOptions = CliCommandOptions_common & {
port: number | undefined;
keycloakVersion: string | undefined;
realmJsonFilePath: string | undefined;
};
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
export async function command(params: {
buildContext: BuildContext;
cliCommandOptions: {
port: number | undefined;
keycloakVersion: string | undefined;
realmJsonFilePath: string | undefined;
};
}) {
exit_if_docker_not_installed: {
let commandOutput: Buffer | undefined = undefined;
let commandOutput: string | undefined = undefined;
try {
commandOutput = child_process.execSync("docker --version", {
stdio: ["ignore", "pipe", "ignore"]
});
commandOutput = child_process
.execSync("docker --version", {
stdio: ["ignore", "pipe", "ignore"]
})
?.toString("utf8");
} catch {}
if (commandOutput?.toString("utf8").includes("Docker")) {
if (commandOutput?.includes("Docker") || commandOutput?.includes("podman")) {
break exit_if_docker_not_installed;
}
@ -86,9 +88,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
process.exit(1);
}
const { cliCommandOptions } = params;
const buildContext = getBuildContext({ cliCommandOptions });
const { cliCommandOptions, buildContext } = params;
const { dockerImageTag } = await (async () => {
if (cliCommandOptions.keycloakVersion !== undefined) {
@ -396,12 +396,12 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
...(realmJsonFilePath === undefined
? []
: [
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), realmJsonFilePath)}":/opt/keycloak/data/import/myrealm-realm.json`
`-v${SPACE_PLACEHOLDER}"${realmJsonFilePath}":/opt/keycloak/data/import/myrealm-realm.json`
]),
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), jarFilePath_cacheDir)}":/opt/keycloak/providers/keycloak-theme.jar`,
`-v${SPACE_PLACEHOLDER}"${jarFilePath_cacheDir}":/opt/keycloak/providers/keycloak-theme.jar`,
...extensionJarFilePaths.map(
jarFilePath =>
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), jarFilePath)}":/opt/keycloak/providers/${pathBasename(jarFilePath)}`
`-v${SPACE_PLACEHOLDER}"${jarFilePath}":/opt/keycloak/providers/${pathBasename(jarFilePath)}`
),
...(keycloakMajorVersionNumber <= 20
? [`-e${SPACE_PLACEHOLDER}JAVA_OPTS=-Dkeycloak.profile=preview`]
@ -424,7 +424,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
}))
.map(
({ localDirPath, containerDirPath }) =>
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), localDirPath)}":${containerDirPath}:rw`
`-v${SPACE_PLACEHOLDER}"${localDirPath}":${containerDirPath}:rw`
),
...buildContext.environmentVariables
.map(({ name }) => ({ name, envValue: process.env[name] }))

View File

@ -1,16 +1,18 @@
import { type FetchOptions } from "make-fetch-happen";
import * as child_process from "child_process";
import * as fs from "fs";
import { exclude } from "tsafe/exclude";
export type ProxyFetchOptions = Pick<
FetchOptions,
"proxy" | "noProxy" | "strictSSL" | "cert" | "ca"
>;
export type FetchOptionsLike = {
proxy: string | undefined;
noProxy: string | string[];
strictSSL: boolean;
cert: string | string[] | undefined;
ca: string[] | undefined;
};
export function getProxyFetchOptions(params: {
npmConfigGetCwd: string;
}): ProxyFetchOptions {
}): FetchOptionsLike {
const { npmConfigGetCwd } = params;
const cfg = (() => {

View File

@ -0,0 +1,71 @@
import * as fs from "fs";
import { dirname as pathDirname } from "path";
import { assert, Equals } from "tsafe/assert";
import chalk from "chalk";
import { id } from "tsafe/id";
import { z } from "zod";
import { is } from "tsafe/is";
import * as child_process from "child_process";
export function runFormat(params: { packageJsonFilePath: string }) {
const { packageJsonFilePath } = params;
const parsedPackageJson = (() => {
type ParsedPackageJson = {
scripts?: Record<string, string>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
scripts: z.record(z.string()).optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const parsedPackageJson = JSON.parse(
fs.readFileSync(packageJsonFilePath).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
})();
const { scripts } = parsedPackageJson;
if (scripts === undefined) {
return;
}
for (const scriptName of ["format", "lint"]) {
if (!(scriptName in scripts)) {
continue;
}
const command = `npm run ${scriptName}`;
console.log(chalk.grey(`$ ${command}`));
try {
child_process.execSync(`npm run ${scriptName}`, {
stdio: "inherit",
cwd: pathDirname(packageJsonFilePath)
});
} catch {
console.log(
chalk.yellow(
`\`${command}\` failed, it does not matter, please format your code manually, continuing...`
)
);
}
return;
}
}

View File

@ -1,13 +1,135 @@
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import { generateKcGenTs } from "./shared/generateKcGenTs";
import type { BuildContext } from "./shared/buildContext";
import * as fs from "fs/promises";
import { join as pathJoin } from "path";
import { existsAsync } from "./tools/fs.existsAsync";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import { runFormat } from "./tools/runFormat";
import * as crypto from "crypto";
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const buildContext = getBuildContext({
cliCommandOptions
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "update-kc-gen",
buildContext
});
await generateKcGenTs({ buildContext });
if (hasBeenHandled) {
return;
}
const filePath = pathJoin(buildContext.themeSrcDirPath, `kc.gen.tsx`);
const hasLoginTheme = buildContext.implementedThemeTypes.login.isImplemented;
const hasAccountTheme = buildContext.implementedThemeTypes.account.isImplemented;
const newContent = [
``,
`/* eslint-disable */`,
``,
`// @ts-nocheck`,
``,
`// noinspection JSUnusedGlobalSymbols`,
``,
`import { lazy, Suspense, type ReactNode } from "react";`,
``,
`export type ThemeName = ${buildContext.themeNames.map(themeName => `"${themeName}"`).join(" | ")};`,
``,
`export const themeNames: ThemeName[] = [${buildContext.themeNames.map(themeName => `"${themeName}"`).join(", ")}];`,
``,
`export type KcEnvName = ${buildContext.environmentVariables.length === 0 ? "never" : buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(" | ")};`,
``,
`export const kcEnvNames: KcEnvName[] = [${buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(", ")}];`,
``,
`export const kcEnvDefaults: Record<KcEnvName, string> = ${JSON.stringify(
Object.fromEntries(
buildContext.environmentVariables.map(
({ name, default: defaultValue }) => [name, defaultValue]
)
),
null,
2
)};`,
``,
`export type KcContext =`,
hasLoginTheme && ` | import("./login/KcContext").KcContext`,
hasAccountTheme && ` | import("./account/KcContext").KcContext`,
` ;`,
``,
`declare global {`,
` interface Window {`,
` kcContext?: KcContext;`,
` }`,
`}`,
``,
hasLoginTheme &&
`export const KcLoginPage = lazy(() => import("./login/KcPage"));`,
hasAccountTheme &&
`export const KcAccountPage = lazy(() => import("./account/KcPage"));`,
``,
`export function KcPage(`,
` props: {`,
` kcContext: KcContext;`,
` fallback?: ReactNode;`,
` }`,
`) {`,
` const { kcContext, fallback } = props;`,
` return (`,
` <Suspense fallback={fallback}>`,
` {(() => {`,
` switch (kcContext.themeType) {`,
hasLoginTheme &&
` case "login": return <KcLoginPage kcContext={kcContext} />;`,
hasAccountTheme &&
` case "account": return <KcAccountPage kcContext={kcContext} />;`,
` }`,
` })()}`,
` </Suspense>`,
` );`,
`}`,
``
]
.filter(item => typeof item === "string")
.join("\n");
const hash = crypto.createHash("sha256").update(newContent).digest("hex");
skip_if_no_changes: {
if (!(await existsAsync(filePath))) {
break skip_if_no_changes;
}
const currentContent = (await fs.readFile(filePath)).toString("utf8");
if (!currentContent.includes(hash)) {
break skip_if_no_changes;
}
return;
}
await fs.writeFile(
filePath,
Buffer.from(
[
`// This file is auto-generated by the \`update-kc-gen\` command. Do not edit it manually.`,
`// Hash: ${hash}`,
``,
newContent
].join("\n"),
"utf8"
)
);
runFormat({ packageJsonFilePath: buildContext.packageJsonFilePath });
delete_legacy_file: {
const legacyFilePath = filePath.replace(/tsx$/, "ts");
if (!(await existsAsync(legacyFilePath))) {
break delete_legacy_file;
}
await fs.unlink(legacyFilePath);
}
}

View File

@ -0,0 +1,252 @@
import { DOMPurify } from "keycloakify/tools/vendor/dompurify";
type TagType = {
name: string;
attributes: AttributeType[];
};
type AttributeType = {
name: string;
matchRegex?: RegExp;
matchFunction?: (value: string) => boolean;
};
// implementation for org.owasp.html.HtmlPolicyBuilder
// https://www.javadoc.io/static/com.googlecode.owasp-java-html-sanitizer/owasp-java-html-sanitizer/20160628.1/index.html?org/owasp/html/HtmlPolicyBuilder.html
// It supports the methods that KCSanitizerPolicy needs and nothing more
export class HtmlPolicyBuilder {
private globalAttributesAllowed: Set<AttributeType> = new Set();
private tagsAllowed: Map<string, TagType> = new Map();
private tagsAllowedWithNoAttribute: Set<string> = new Set();
private currentAttribute: AttributeType | null = null;
private isStylingAllowed: boolean = false;
private allowedProtocols: Set<string> = new Set();
private enforceRelNofollow: boolean = false;
private DOMPurify: typeof DOMPurify;
// add a constructor
constructor(
dependencyInjections: Partial<{
DOMPurify: typeof DOMPurify;
}>
) {
this.DOMPurify = dependencyInjections.DOMPurify ?? DOMPurify;
}
allowWithoutAttributes(tag: string): this {
this.tagsAllowedWithNoAttribute.add(tag);
return this;
}
// Adds the attributes for validation
allowAttributes(...args: string[]): this {
if (args.length) {
const attr = args[0];
this.currentAttribute = { name: attr }; // Default regex, will be set later
}
return this;
}
// Matching regex for value of allowed attributes
matching(matchingPattern: RegExp | ((value: string) => boolean)): this {
if (this.currentAttribute) {
if (matchingPattern instanceof RegExp) {
this.currentAttribute.matchRegex = matchingPattern;
} else {
this.currentAttribute.matchFunction = matchingPattern;
}
}
return this;
}
// Make attributes in prev call global
globally(): this {
if (this.currentAttribute) {
this.currentAttribute.matchRegex = /.*/;
this.globalAttributesAllowed.add(this.currentAttribute);
this.currentAttribute = null; // Reset after global application
}
return this;
}
// Allow styling globally
allowStyling(): this {
this.isStylingAllowed = true;
return this;
}
// Save attributes for specific tag
onElements(...tags: string[]): this {
if (this.currentAttribute) {
tags.forEach(tag => {
const element = this.tagsAllowed.get(tag) || {
name: tag,
attributes: []
};
element.attributes.push(this.currentAttribute!);
this.tagsAllowed.set(tag, element);
});
this.currentAttribute = null; // Reset after applying to elements
}
return this;
}
// Make specific tag allowed
allowElements(...tags: string[]): this {
tags.forEach(tag => {
if (!this.tagsAllowed.has(tag)) {
this.tagsAllowed.set(tag, { name: tag, attributes: [] });
}
});
return this;
}
// Handle rel=nofollow on links
requireRelNofollowOnLinks(): this {
this.enforceRelNofollow = true;
return this;
}
// Allow standard URL protocols (could include further implementation)
allowStandardUrlProtocols(): this {
this.allowedProtocols.add("http");
this.allowedProtocols.add("https");
this.allowedProtocols.add("mailto");
return this;
}
apply(html: string): string {
//Clear all previous configs first ( in case we used DOMPurify somewhere else )
this.DOMPurify.clearConfig();
this.DOMPurify.removeAllHooks();
this.setupHooks();
return this.DOMPurify.sanitize(html, {
ALLOWED_TAGS: Array.from(this.tagsAllowed.keys()),
ALLOWED_ATTR: this.getAllowedAttributes(),
ALLOWED_URI_REGEXP: this.getAllowedUriRegexp(),
ADD_TAGS: this.isStylingAllowed ? ["style"] : [],
ADD_ATTR: this.isStylingAllowed ? ["style"] : []
});
}
private setupHooks(): void {
// Check allowed attribute and global attributes and it doesnt exist in them remove it
this.DOMPurify.addHook("uponSanitizeAttribute", (currentNode, hookEvent) => {
if (!hookEvent) return;
const tagName = currentNode.tagName.toLowerCase();
const allowedAttributes = this.tagsAllowed.get(tagName)?.attributes || [];
//Add global attributes to allowed attributes
this.globalAttributesAllowed.forEach(attribute => {
allowedAttributes.push(attribute);
});
//Add style attribute to allowed attributes
if (this.isStylingAllowed) {
let styleAttribute: AttributeType = { name: "style", matchRegex: /.*/ };
allowedAttributes.push(styleAttribute);
}
// Check if the attribute is allowed
if (!allowedAttributes.some(attr => attr.name === hookEvent.attrName)) {
hookEvent.forceKeepAttr = false;
hookEvent.keepAttr = false;
currentNode.removeAttribute(hookEvent.attrName);
return;
} else {
const attributeType = allowedAttributes.find(
attr => attr.name === hookEvent.attrName
);
if (attributeType) {
//Check if attribute value is allowed
if (
attributeType.matchRegex &&
!attributeType.matchRegex.test(hookEvent.attrValue)
) {
hookEvent.forceKeepAttr = false;
hookEvent.keepAttr = false;
currentNode.removeAttribute(hookEvent.attrName);
return;
}
if (
attributeType.matchFunction &&
!attributeType.matchFunction(hookEvent.attrValue)
) {
hookEvent.forceKeepAttr = false;
hookEvent.keepAttr = false;
currentNode.removeAttribute(hookEvent.attrName);
return;
}
}
}
// both attribute and value already checked so they should be ok
// set forceKeep to true to make sure next hooks won't delete them
// except for href that we will check later
if (hookEvent.attrName !== "href") {
hookEvent.keepAttr = true;
hookEvent.forceKeepAttr = true;
}
});
this.DOMPurify.addHook("afterSanitizeAttributes", currentNode => {
// if tag is not allowed to have no attribute then remove it completely
if (
currentNode.attributes.length == 0 &&
currentNode.childNodes.length == 0
) {
if (!this.tagsAllowedWithNoAttribute.has(currentNode.tagName)) {
currentNode.remove();
}
} else {
//in case of <a> or <img> if we have no attribute we need to remove them even if they have child
if (currentNode.tagName === "A" || currentNode.tagName === "IMG") {
if (currentNode.attributes.length == 0) {
//add currentNode children to parent node
while (currentNode.firstChild) {
currentNode?.parentNode?.insertBefore(
currentNode.firstChild,
currentNode
);
}
// Remove the currentNode itself
currentNode.remove();
}
}
//
if (currentNode.tagName === "A") {
if (this.enforceRelNofollow) {
if (!currentNode.hasAttribute("rel")) {
currentNode.setAttribute("rel", "nofollow");
} else if (
!currentNode.getAttribute("rel")?.includes("nofollow")
) {
currentNode.setAttribute(
"rel",
currentNode.getAttribute("rel") + " nofollow"
);
}
}
}
}
});
}
private getAllowedAttributes(): string[] {
const allowedAttributes: Set<string> = new Set();
this.tagsAllowed.forEach(element => {
element.attributes.forEach(attribute => {
allowedAttributes.add(attribute.name);
});
});
this.globalAttributesAllowed.forEach(attribute => {
allowedAttributes.add(attribute.name);
});
return Array.from(allowedAttributes);
}
private getAllowedUriRegexp(): RegExp {
const protocols = Array.from(this.allowedProtocols).join("|");
return new RegExp(`^(?:${protocols})://`, "i");
}
}

View File

@ -0,0 +1,60 @@
import { KcSanitizerPolicy } from "./KcSanitizerPolicy";
import type { DOMPurify as ofTypeDomPurify } from "keycloakify/tools/vendor/dompurify";
// implementation of keycloak java sanitize method ( KeycloakSanitizerMethod )
// https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/main/java/org/keycloak/theme/KeycloakSanitizerMethod.java#L33
export class KcSanitizer {
private static HREF_PATTERN = /\s+href="([^"]*)"/g;
private static textarea: HTMLTextAreaElement | null = null;
public static sanitize(
html: string,
dependencyInjections: Partial<{
DOMPurify: typeof ofTypeDomPurify;
htmlEntitiesDecode: (html: string) => string;
}>
): string {
if (html === "") return "";
html =
dependencyInjections?.htmlEntitiesDecode !== undefined
? dependencyInjections.htmlEntitiesDecode(html)
: this.decodeHtml(html);
const sanitized = KcSanitizerPolicy.sanitize(html, dependencyInjections);
return this.fixURLs(sanitized);
}
private static decodeHtml(html: string): string {
if (!KcSanitizer.textarea) {
KcSanitizer.textarea = document.createElement("textarea");
}
KcSanitizer.textarea.innerHTML = html;
return KcSanitizer.textarea.value;
}
// This will remove unwanted characters from url
private static fixURLs(msg: string): string {
const HREF_PATTERN = this.HREF_PATTERN;
const result = [];
let last = 0;
let match: RegExpExecArray | null;
do {
match = HREF_PATTERN.exec(msg);
if (match) {
const href = match[0]
.replace(/&#61;/g, "=")
.replace(/\.\./g, ".")
.replace(/&amp;/g, "&");
result.push(msg.substring(last, match.index!));
result.push(href);
last = HREF_PATTERN.lastIndex;
}
} while (match);
result.push(msg.substring(last));
return result.join("");
}
}

View File

@ -0,0 +1,294 @@
import { HtmlPolicyBuilder } from "./HtmlPolicyBuilder";
import type { DOMPurify as ofTypeDomPurify } from "keycloakify/tools/vendor/dompurify";
//implementation of java Sanitizer policy ( KeycloakSanitizerPolicy )
// All regex directly copied from the keycloak source but some of them changed slightly to work with typescript(ONSITE_URL and OFFSITE_URL)
// Also replaced ?i with "i" tag as second parameter of RegExp
//https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/main/java/org/keycloak/theme/KeycloakSanitizerPolicy.java#L29
export class KcSanitizerPolicy {
public static readonly COLOR_NAME = new RegExp(
"(?:aqua|black|blue|fuchsia|gray|grey|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)"
);
public static readonly COLOR_CODE = new RegExp(
"(?:#(?:[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?))"
);
public static readonly NUMBER_OR_PERCENT = new RegExp("[0-9]+%?");
public static readonly PARAGRAPH = new RegExp(
"(?:[\\p{L}\\p{N},'\\.\\s\\-_\\(\\)]|&[0-9]{2};)*",
"u" // Unicode flag for \p{L} and \p{N} in the pattern
);
public static readonly HTML_ID = new RegExp("[a-zA-Z0-9\\:\\-_\\.]+");
public static readonly HTML_TITLE = new RegExp(
"[\\p{L}\\p{N}\\s\\-_',:\\[\\]!\\./\\\\\\(\\)&]*",
"u" // Unicode flag for \p{L} and \p{N} in the pattern
);
public static readonly HTML_CLASS = new RegExp("[a-zA-Z0-9\\s,\\-_]+");
public static readonly ONSITE_URL = new RegExp(
"(?:[\\p{L}\\p{N}.#@\\$%+&;\\-_~,?=/!]+|#(\\w)+)",
"u" // Unicode flag for \p{L} and \p{N} in the pattern
);
public static readonly OFFSITE_URL = new RegExp(
"\\s*(?:(?:ht|f)tps?://|mailto:)[\\p{L}\\p{N}]+" +
"[\\p{L}\\p{N}\\p{Zs}.#@\\$%+&;:\\-_~,?=/!()]*\\s*",
"u" // Unicode flag for \p{L} and \p{N} in the pattern
);
public static readonly NUMBER = new RegExp(
"[+-]?(?:(?:[0-9]+(?:\\.[0-9]*)?)|\\.[0-9]+)"
);
public static readonly NAME = new RegExp("[a-zA-Z0-9\\-_\\$]+");
public static readonly ALIGN = new RegExp(
"\\b(center|left|right|justify|char)\\b",
"i" // Case-insensitive flag
);
public static readonly VALIGN = new RegExp(
"\\b(baseline|bottom|middle|top)\\b",
"i" // Case-insensitive flag
);
public static readonly HISTORY_BACK = new RegExp(
"(?:javascript:)?\\Qhistory.go(-1)\\E"
);
public static readonly ONE_CHAR = new RegExp(
".?",
"s" // Dotall flag for . to match newlines
);
private static COLOR_NAME_OR_COLOR_CODE(s: string): boolean {
return (
KcSanitizerPolicy.COLOR_NAME.test(s) || KcSanitizerPolicy.COLOR_CODE.test(s)
);
}
private static ONSITE_OR_OFFSITE_URL(s: string): boolean {
return (
KcSanitizerPolicy.ONSITE_URL.test(s) || KcSanitizerPolicy.OFFSITE_URL.test(s)
);
}
public static sanitize(
html: string,
dependencyInjections: Partial<{
DOMPurify: typeof ofTypeDomPurify;
}>
): string {
return new HtmlPolicyBuilder(dependencyInjections)
.allowWithoutAttributes("span")
.allowAttributes("id")
.matching(this.HTML_ID)
.globally()
.allowAttributes("class")
.matching(this.HTML_CLASS)
.globally()
.allowAttributes("lang")
.matching(/[a-zA-Z]{2,20}/)
.globally()
.allowAttributes("title")
.matching(this.HTML_TITLE)
.globally()
.allowStyling()
.allowAttributes("align")
.matching(this.ALIGN)
.onElements("p")
.allowAttributes("for")
.matching(this.HTML_ID)
.onElements("label")
.allowAttributes("color")
.matching(this.COLOR_NAME_OR_COLOR_CODE)
.onElements("font")
.allowAttributes("face")
.matching(/[\w;, \-]+/)
.onElements("font")
.allowAttributes("size")
.matching(this.NUMBER)
.onElements("font")
.allowAttributes("href")
.matching(this.ONSITE_OR_OFFSITE_URL)
.onElements("a")
.allowStandardUrlProtocols()
.allowAttributes("nohref")
.onElements("a")
.allowAttributes("name")
.matching(this.NAME)
.onElements("a")
.allowAttributes("onfocus", "onblur", "onclick", "onmousedown", "onmouseup")
.matching(this.HISTORY_BACK)
.onElements("a")
.requireRelNofollowOnLinks()
.allowAttributes("src")
.matching(this.ONSITE_OR_OFFSITE_URL)
.onElements("img")
.allowAttributes("name")
.matching(this.NAME)
.onElements("img")
.allowAttributes("alt")
.matching(this.PARAGRAPH)
.onElements("img")
.allowAttributes("border", "hspace", "vspace")
.matching(this.NUMBER)
.onElements("img")
.allowAttributes("border", "cellpadding", "cellspacing")
.matching(this.NUMBER)
.onElements("table")
.allowAttributes("bgcolor")
.matching(this.COLOR_NAME_OR_COLOR_CODE)
.onElements("table")
.allowAttributes("background")
.matching(this.ONSITE_URL)
.onElements("table")
.allowAttributes("align")
.matching(this.ALIGN)
.onElements("table")
.allowAttributes("noresize")
.matching(new RegExp("noresize", "i"))
.onElements("table")
.allowAttributes("background")
.matching(this.ONSITE_URL)
.onElements("td", "th", "tr")
.allowAttributes("bgcolor")
.matching(this.COLOR_NAME_OR_COLOR_CODE)
.onElements("td", "th")
.allowAttributes("abbr")
.matching(this.PARAGRAPH)
.onElements("td", "th")
.allowAttributes("axis", "headers")
.matching(this.NAME)
.onElements("td", "th")
.allowAttributes("scope")
.matching(new RegExp("(?:row|col)(?:group)?", "i"))
.onElements("td", "th")
.allowAttributes("nowrap")
.onElements("td", "th")
.allowAttributes("height", "width")
.matching(this.NUMBER_OR_PERCENT)
.onElements("table", "td", "th", "tr", "img")
.allowAttributes("align")
.matching(this.ALIGN)
.onElements(
"thead",
"tbody",
"tfoot",
"img",
"td",
"th",
"tr",
"colgroup",
"col"
)
.allowAttributes("valign")
.matching(this.VALIGN)
.onElements("thead", "tbody", "tfoot", "td", "th", "tr", "colgroup", "col")
.allowAttributes("charoff")
.matching(this.NUMBER_OR_PERCENT)
.onElements("td", "th", "tr", "colgroup", "col", "thead", "tbody", "tfoot")
.allowAttributes("char")
.matching(this.ONE_CHAR)
.onElements("td", "th", "tr", "colgroup", "col", "thead", "tbody", "tfoot")
.allowAttributes("colspan", "rowspan")
.matching(this.NUMBER)
.onElements("td", "th")
.allowAttributes("span", "width")
.matching(this.NUMBER_OR_PERCENT)
.onElements("colgroup", "col")
.allowElements(
"a",
"label",
"noscript",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"i",
"b",
"u",
"strong",
"em",
"small",
"big",
"pre",
"code",
"cite",
"samp",
"sub",
"sup",
"strike",
"center",
"blockquote",
"hr",
"br",
"col",
"font",
"map",
"span",
"div",
"img",
"ul",
"ol",
"li",
"dd",
"dt",
"dl",
"tbody",
"thead",
"tfoot",
"table",
"td",
"th",
"tr",
"colgroup",
"fieldset",
"legend"
)
.apply(html);
}
}

View File

@ -0,0 +1,5 @@
import { KcSanitizer } from "./KcSanitizer";
export function kcSanitize(html: string): string {
return KcSanitizer.sanitize(html, {});
}

View File

@ -2,7 +2,7 @@ import type { ThemeType, LoginThemePageId } from "keycloakify/bin/shared/constan
import type { ValueOf } from "keycloakify/tools/ValueOf";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import type { ClassKey } from "keycloakify/login/TemplateProps";
import type { ClassKey } from "keycloakify/login/lib/kcClsx";
export type ExtendKcContext<
KcContextExtension extends { properties?: Record<string, string | undefined> },
@ -94,6 +94,7 @@ export declare namespace KcContext {
languageTag: string;
}[];
currentLanguageTag: string;
rtl?: boolean;
};
auth?: {
showUsername?: boolean;
@ -101,7 +102,7 @@ export declare namespace KcContext {
showTryAnotherWayLink?: boolean;
attemptedUsername?: string;
};
scripts: string[];
scripts?: string[];
message?: {
type: "success" | "warning" | "error" | "info";
summary: string;

View File

@ -7,6 +7,7 @@ import {
import { id } from "tsafe/id";
import { assert, type Equals } from "tsafe/assert";
import { BASE_URL } from "keycloakify/lib/BASE_URL";
import type { LanguageTag } from "keycloakify/login/i18n/messages_defaultSet/types";
const attributesByName = Object.fromEntries(
id<Attribute[]>([
@ -75,7 +76,7 @@ const attributesByName = Object.fromEntries(
]).map(attribute => [attribute.name, attribute])
);
const resourcesPath = `${BASE_URL}${WELL_KNOWN_DIRECTORY_BASE_NAME.DOT_KEYCLOAKIFY}/login`;
const resourcesPath = `${BASE_URL}${WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES}/login`;
export const kcContextCommonMock: KcContext.Common = {
themeVersion: "0.0.0",
@ -116,35 +117,59 @@ export const kcContextCommonMock: KcContext.Common = {
}
},
locale: {
supported: [
/* spell-checker: disable */
["de", "Deutsch"],
["no", "Norsk"],
["ru", "Русский"],
["sv", "Svenska"],
["pt-BR", "Português (Brasil)"],
["lt", "Lietuvių"],
["en", "English"],
["it", "Italiano"],
["fr", "Français"],
["zh-CN", "中文简体"],
["es", "Español"],
["cs", "Čeština"],
["ja", "日本語"],
["sk", "Slovenčina"],
["pl", "Polski"],
["ca", "Català"],
["nl", "Nederlands"],
["tr", "Türkçe"]
/* spell-checker: enable */
].map(
([languageTag, label]) =>
({
languageTag,
label,
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
}) as const
),
supported: (
[
/* spell-checker: disable */
["de", "Deutsch"],
["no", "Norsk"],
["ru", "Русский"],
["sv", "Svenska"],
["pt-BR", "Português (Brasil)"],
["lt", "Lietuvių"],
["en", "English"],
["it", "Italiano"],
["fr", "Français"],
["zh-CN", "中文简体"],
["es", "Español"],
["cs", "Čeština"],
["ja", "日本語"],
["sk", "Slovenčina"],
["pl", "Polski"],
["ca", "Català"],
["nl", "Nederlands"],
["tr", "Türkçe"],
["ar", "العربية"],
["da", "Dansk"],
["el", "Ελληνικά"],
["fa", "فارسی"],
["fi", "Suomi"],
["hu", "Magyar"],
["ka", "ქართული"],
["lv", "Latviešu"],
["pt", "Português"],
["th", "ไทย"],
["uk", "Українська"],
["zh-TW", "中文繁體"]
/* spell-checker: enable */
] as const
).map(([languageTag, label]) => {
{
type Got = typeof languageTag;
type Expected = LanguageTag;
type Missing = Exclude<Expected, Got>;
type Unexpected = Exclude<Got, Expected>;
assert<Equals<Missing, never>>;
assert<Equals<Unexpected, never>>;
}
return {
languageTag,
label,
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
} as const;
}),
currentLanguageTag: "en"
},

View File

@ -1,10 +1,10 @@
import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { TemplateProps } from "keycloakify/login/TemplateProps";
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import { useStylesAndScripts } from "keycloakify/login/Template.useStylesAndScripts";
import { useInitialize } from "keycloakify/login/Template.useInitialize";
import type { I18n } from "./i18n";
import type { KcContext } from "./KcContext";
@ -27,9 +27,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { msg, msgStr, getChangeLocaleUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
const { msg, msgStr, currentLanguage, enabledLanguages } = i18n;
const { realm, locale, auth, url, message, isAppInitiatedAction } = kcContext;
const { realm, auth, url, message, isAppInitiatedAction } = kcContext;
useEffect(() => {
document.title = documentTitle ?? msgStr("loginTitle", kcContext.realm.displayName);
@ -45,7 +45,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
className: bodyClassName ?? kcClsx("kcBodyClass")
});
const { isReadyToRender } = useStylesAndScripts({ kcContext, doUseDefaultCss });
const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss });
if (!isReadyToRender) {
return null;
@ -58,10 +58,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
{msg("loginTitleHtml", realm.displayNameHtml)}
</div>
</div>
<div className={kcClsx("kcFormCardClass")}>
<header className={kcClsx("kcFormHeaderClass")}>
{realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && (
{enabledLanguages.length > 1 && (
<div className={kcClsx("kcLocaleMainClass")} id="kc-locale">
<div id="kc-locale-wrapper" className={kcClsx("kcLocaleWrapperClass")}>
<div id="kc-locale-dropdown" className={clsx("menu-button-links", kcClsx("kcLocaleDropDownClass"))}>
@ -73,7 +72,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
aria-expanded="false"
aria-controls="language-switch1"
>
{labelBySupportedLanguageTag[currentLanguageTag]}
{currentLanguage.label}
</button>
<ul
role="menu"
@ -83,15 +82,10 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
id="language-switch1"
className={kcClsx("kcLocaleListClass")}
>
{locale.supported.map(({ languageTag }, i) => (
{enabledLanguages.map(({ languageTag, label, href }, i) => (
<li key={languageTag} className={kcClsx("kcLocaleListItemClass")} role="none">
<a
role="menuitem"
id={`language-${i + 1}`}
className={kcClsx("kcLocaleItemClass")}
href={getChangeLocaleUrl(languageTag)}
>
{labelBySupportedLanguageTag[languageTag]}
<a role="menuitem" id={`language-${i + 1}`} className={kcClsx("kcLocaleItemClass")} href={href}>
{label}
</a>
</li>
))}
@ -152,7 +146,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
<span
className={kcClsx("kcAlertTitleClass")}
dangerouslySetInnerHTML={{
__html: message.summary
__html: kcSanitize(message.summary)
}}
/>
</div>

View File

@ -2,7 +2,7 @@ import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { KcContext } from "keycloakify/login/KcContext/KcContext";
import type { KcContext } from "keycloakify/login/KcContext";
export type KcContextLike = {
url: {
@ -10,34 +10,19 @@ export type KcContextLike = {
resourcesPath: string;
ssoLoginInOtherTabsUrl: string;
};
locale?: {
currentLanguageTag: string;
};
scripts: string[];
scripts?: string[];
};
assert<keyof KcContextLike extends keyof KcContext ? true : false>();
assert<KcContext extends KcContextLike ? true : false>();
export function useStylesAndScripts(params: {
export function useInitialize(params: {
kcContext: KcContextLike;
doUseDefaultCss: boolean;
}) {
const { kcContext, doUseDefaultCss } = params;
const { url, locale, scripts } = kcContext;
useEffect(() => {
const { currentLanguageTag } = locale ?? {};
if (currentLanguageTag === undefined) {
return;
}
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
}, []);
const { url, scripts } = kcContext;
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
@ -55,30 +40,23 @@ export function useStylesAndScripts(params: {
const { insertScriptTags } = useInsertScriptTags({
componentOrHookName: "Template",
scriptTags: [
{
type: "importmap",
textContent: JSON.stringify({
imports: {
rfc4648: `${url.resourcesCommonPath}/node_modules/rfc4648/lib/rfc4648.js`
}
})
},
// NOTE: The importmap is added in by the FTL script because it's too late to add it here.
{
type: "module",
src: `${url.resourcesPath}/js/menu-button-links.js`
},
...scripts.map(src => ({
type: "text/javascript" as const,
src
})),
...(scripts === undefined
? []
: scripts.map(src => ({
type: "text/javascript" as const,
src
}))),
{
type: "module",
textContent: `
import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";
checkCookiesAndSetTimer(
"${url.ssoLoginInOtherTabsUrl}"
);
checkCookiesAndSetTimer("${url.ssoLoginInOtherTabsUrl}");
`
}
]

View File

@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import type { ClassKey } from "keycloakify/login/lib/kcClsx";
export type TemplateProps<KcContext, I18n> = {
kcContext: KcContext;
@ -18,128 +19,4 @@ export type TemplateProps<KcContext, I18n> = {
bodyClassName?: string;
};
export type ClassKey =
| "kcBodyClass"
| "kcHeaderWrapperClass"
| "kcLocaleWrapperClass"
| "kcInfoAreaWrapperClass"
| "kcFormButtonsWrapperClass"
| "kcFormOptionsWrapperClass"
| "kcCheckboxInputClass"
| "kcLocaleDropDownClass"
| "kcLocaleListItemClass"
| "kcContentWrapperClass"
| "kcLogoIdP-facebook"
| "kcAuthenticatorOTPClass"
| "kcLogoIdP-bitbucket"
| "kcAuthenticatorWebAuthnClass"
| "kcWebAuthnDefaultIcon"
| "kcLogoIdP-stackoverflow"
| "kcSelectAuthListItemClass"
| "kcLogoIdP-microsoft"
| "kcLoginOTPListItemHeaderClass"
| "kcLocaleItemClass"
| "kcLoginOTPListItemIconBodyClass"
| "kcInputHelperTextAfterClass"
| "kcFormClass"
| "kcSelectAuthListClass"
| "kcInputClassRadioCheckboxLabelDisabled"
| "kcSelectAuthListItemIconClass"
| "kcRecoveryCodesWarning"
| "kcFormSettingClass"
| "kcWebAuthnBLE"
| "kcInputWrapperClass"
| "kcSelectAuthListItemArrowIconClass"
| "kcFeedbackAreaClass"
| "kcFormPasswordVisibilityButtonClass"
| "kcLogoIdP-google"
| "kcCheckLabelClass"
| "kcSelectAuthListItemFillClass"
| "kcAuthenticatorDefaultClass"
| "kcLogoIdP-gitlab"
| "kcFormAreaClass"
| "kcFormButtonsClass"
| "kcInputClassRadioLabel"
| "kcAuthenticatorWebAuthnPasswordlessClass"
| "kcSelectAuthListItemHeadingClass"
| "kcInfoAreaClass"
| "kcLogoLink"
| "kcContainerClass"
| "kcSelectAuthListItemTitle"
| "kcHtmlClass"
| "kcLoginOTPListItemTitleClass"
| "kcLogoIdP-openshift-v4"
| "kcWebAuthnUnknownIcon"
| "kcFormSocialAccountNameClass"
| "kcLogoIdP-openshift-v3"
| "kcLoginOTPListInputClass"
| "kcWebAuthnUSB"
| "kcInputClassRadio"
| "kcWebAuthnKeyIcon"
| "kcFeedbackInfoIcon"
| "kcCommonLogoIdP"
| "kcRecoveryCodesActions"
| "kcFormGroupHeader"
| "kcFormSocialAccountSectionClass"
| "kcLogoIdP-instagram"
| "kcAlertClass"
| "kcHeaderClass"
| "kcLabelWrapperClass"
| "kcFormPasswordVisibilityIconShow"
| "kcFormSocialAccountLinkClass"
| "kcLocaleMainClass"
| "kcInputGroup"
| "kcTextareaClass"
| "kcButtonBlockClass"
| "kcButtonClass"
| "kcWebAuthnNFC"
| "kcLocaleClass"
| "kcInputClassCheckboxInput"
| "kcFeedbackErrorIcon"
| "kcInputLargeClass"
| "kcInputErrorMessageClass"
| "kcRecoveryCodesList"
| "kcFormSocialAccountListClass"
| "kcAlertTitleClass"
| "kcAuthenticatorPasswordClass"
| "kcCheckInputClass"
| "kcLogoIdP-linkedin"
| "kcLogoIdP-twitter"
| "kcFeedbackWarningIcon"
| "kcResetFlowIcon"
| "kcSelectAuthListItemIconPropertyClass"
| "kcFeedbackSuccessIcon"
| "kcLoginOTPListClass"
| "kcSrOnlyClass"
| "kcFormSocialAccountListGridClass"
| "kcButtonDefaultClass"
| "kcFormGroupErrorClass"
| "kcSelectAuthListItemDescriptionClass"
| "kcSelectAuthListItemBodyClass"
| "kcWebAuthnInternal"
| "kcSelectAuthListItemArrowClass"
| "kcCheckClass"
| "kcContentClass"
| "kcLogoClass"
| "kcLoginOTPListItemIconClass"
| "kcLoginClass"
| "kcSignUpClass"
| "kcButtonLargeClass"
| "kcFormCardClass"
| "kcLocaleListClass"
| "kcInputClass"
| "kcFormGroupClass"
| "kcLogoIdP-paypal"
| "kcInputClassCheckbox"
| "kcRecoveryCodesConfirmation"
| "kcFormPasswordVisibilityIconHide"
| "kcInputClassRadioInput"
| "kcFormSocialAccountListButtonClass"
| "kcInputClassCheckboxLabel"
| "kcFormOptionsClass"
| "kcFormHeaderClass"
| "kcFormSocialAccountGridItem"
| "kcButtonPrimaryClass"
| "kcInputHelperTextBeforeClass"
| "kcLogoIdP-github"
| "kcLabelClass";
export type { ClassKey };

View File

@ -434,9 +434,7 @@ function AddRemoveButtonsMultiValuedAttribute(props: {
}
function InputTagSelects(props: InputFieldByTypeProps) {
const { attribute, dispatchFormAction, kcClsx, valueOrValues } = props;
const { advancedMsg } = props.i18n;
const { attribute, dispatchFormAction, kcClsx, i18n, valueOrValues } = props;
const { classDiv, classInput, classLabel, inputType } = (() => {
const { inputType } = attribute.annotations;
@ -533,7 +531,7 @@ function InputTagSelects(props: InputFieldByTypeProps) {
htmlFor={`${attribute.name}-${option}`}
className={`${classLabel}${attribute.readOnly ? ` ${kcClsx("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
>
{advancedMsg(option)}
{inputLabel(i18n, attribute, option)}
</label>
</div>
))}
@ -580,8 +578,6 @@ function TextareaTag(props: InputFieldByTypeProps) {
function SelectTag(props: InputFieldByTypeProps) {
const { attribute, dispatchFormAction, kcClsx, displayableErrors, i18n, valueOrValues } = props;
const { advancedMsgStr } = i18n;
const isMultiple = attribute.annotations.inputType === "multiselect";
return (
@ -645,22 +641,26 @@ function SelectTag(props: InputFieldByTypeProps) {
return options.map(option => (
<option key={option} value={option}>
{(() => {
if (attribute.annotations.inputOptionLabels !== undefined) {
const { inputOptionLabels } = attribute.annotations;
return advancedMsgStr(inputOptionLabels[option] ?? option);
}
if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) {
return advancedMsgStr(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
}
return option;
})()}
{inputLabel(i18n, attribute, option)}
</option>
));
})()}
</select>
);
}
function inputLabel(i18n: I18n, attribute: Attribute, option: string) {
const { advancedMsg } = i18n;
if (attribute.annotations.inputOptionLabels !== undefined) {
const { inputOptionLabels } = attribute.annotations;
return advancedMsg(inputOptionLabels[option] ?? option);
}
if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) {
return advancedMsg(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
}
return option;
}

View File

@ -1,6 +0,0 @@
import type { GenericI18n_noJsx } from "./i18n";
export type GenericI18n<MessageKey extends string> = GenericI18n_noJsx<MessageKey> & {
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
};

View File

@ -1,252 +0,0 @@
import "keycloakify/tools/Object.fromEntries";
import { assert } from "tsafe/assert";
import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en";
import { fetchMessages_defaultSet } from "./messages_defaultSet";
import type { KcContext } from "../KcContext";
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
export type KcContextLike = {
locale?: {
currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[];
};
"x-keycloakify": {
messages: Record<string, string>;
};
};
assert<KcContext extends KcContextLike ? true : false>();
export type GenericI18n_noJsx<MessageKey extends string> = {
/**
* e.g: "en", "fr", "zh-CN"
*
* The current language
*/
currentLanguageTag: string;
/**
* Redirect to this url to change the language.
* After reload currentLanguageTag === newLanguageTag
*/
getChangeLocaleUrl: (newLanguageTag: string) => string;
/**
* e.g. "en" => "English", "fr" => "Français", ...
*
* Used to render a select that enable user to switch language.
* ex: https://user-images.githubusercontent.com/6702424/186044799-38801eec-4e89-483b-81dd-8e9233e8c0eb.png
* */
labelBySupportedLanguageTag: Record<string, string>;
/**
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
* msgStr("${bar}", "<strong>c</strong>") === "Bar &lt;strong&gt;XXX&lt;/strong&gt;"
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
*/
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
/**
* This is meant to be used when the key argument is variable, something that might have been configured by the user
* in the Keycloak admin for example.
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* }
* }
*
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
};
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
export function createGetI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag_themeDefined: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
function getI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
use_cache: {
const cachedResult = cachedResultByKcContext.get(kcContext);
if (cachedResult === undefined) {
break use_cache;
}
return cachedResult;
}
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocaleUrl" | "labelBySupportedLanguageTag"> = {
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG,
getChangeLocaleUrl: newLanguageTag => {
const { locale } = kcContext;
assert(locale !== undefined, "Internationalization not enabled");
const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag);
assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`);
return targetSupportedLocale.url;
},
labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]))
};
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
messages_themeDefined:
messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ??
messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG] ??
(() => {
const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0];
if (firstLanguageTag === undefined) {
return undefined;
}
return messagesByLanguageTag_themeDefined[firstLanguageTag];
})(),
messages_fromKcServer: kcContext["x-keycloakify"].messages
});
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === FALLBACK_LANGUAGE_TAG;
const result: Result = {
i18n: {
...partialI18n,
...createI18nTranslationFunctions({
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
}),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag);
const i18n_currentLanguage: I18n = {
...partialI18n,
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
isFetchingTranslations: false
};
// NOTE: This promise.resolve is just because without it we TypeScript
// gives a Variable 'result' is used before being assigned. error
await Promise.resolve().then(() => {
result.i18n = i18n_currentLanguage;
result.prI18n_currentLanguage = undefined;
});
return i18n_currentLanguage;
})()
};
cachedResultByKcContext.set(kcContext, result);
return result;
}
return { getI18n };
}
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
messages_themeDefined: Record<MessageKey_themeDefined, string> | undefined;
messages_fromKcServer: Record<string, string>;
}) {
const { messages_themeDefined, messages_fromKcServer } = params;
function createI18nTranslationFunctions(params: {
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>, "msgStr" | "advancedMsgStr"> {
const { messages_defaultSet_currentLanguage } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
const { key, args } = props;
const message =
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
id<Record<string, string | undefined> | undefined>(messages_themeDefined)?.[key] ??
id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];
if (message === undefined) {
return undefined;
}
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
if (startIndex === undefined) {
// No {0} in message (no arguments expected)
return message;
}
let messageWithArgsInjected = message;
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
(() => {
if (key === "loginTitleHtml") {
return arg;
}
return arg.replace(/</g, "&lt;").replace(/>/g, "&gt;");
})()
);
});
return messageWithArgsInjected;
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
const { key, args } = props;
const match = key.match(/^\$\{(.+)\}$/);
return resolveMsg({ key: match !== null ? match[1] : key, args }) ?? key;
}
return {
msgStr: (key, ...args) => {
const resolvedMessage = resolveMsg({ key, args });
assert(resolvedMessage !== undefined, `Message with key "${key}" not found`);
return resolvedMessage;
},
advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args })
};
}
return { createI18nTranslationFunctions };
}

View File

@ -1,5 +1,5 @@
import type { GenericI18n } from "./GenericI18n";
import type { MessageKey_defaultSet, KcContextLike } from "./i18n";
export type { MessageKey_defaultSet, KcContextLike };
export type I18n = GenericI18n<MessageKey_defaultSet>;
export { createUseI18n } from "./useI18n";
export * from "./withJsx";
import type { GenericI18n } from "./withJsx/GenericI18n";
import type { MessageKey as MessageKey_defaultSet } from "./messages_defaultSet/types";
/** INTERNAL: DO NOT IMPORT THIS */
export type I18n = GenericI18n<MessageKey_defaultSet, string>;

View File

@ -0,0 +1,64 @@
export type GenericI18n_noJsx<MessageKey extends string, LanguageTag extends string> = {
currentLanguage: {
/**
* e.g: "en", "fr", "zh-CN"
*
* The current language
*/
languageTag: LanguageTag;
/**
* e.g: "English", "Français", "中文(简体)"
*
* The current language
*/
label: string;
};
/**
* Array of languages enabled on the realm.
*/
enabledLanguages: {
languageTag: LanguageTag;
label: string;
href: string;
}[];
/**
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
* msgStr("${bar}", "<strong>c</strong>") === "Bar &lt;strong&gt;XXX&lt;/strong&gt;"
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
*/
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
/**
* This is meant to be used when the key argument is variable, something that might have been configured by the user
* in the Keycloak admin for example.
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* }
* }
*
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
};

View File

@ -0,0 +1,385 @@
import "keycloakify/tools/Object.fromEntries";
import { assert } from "tsafe/assert";
import messages_defaultSet_fallbackLanguage from "../messages_defaultSet/en";
import { fetchMessages_defaultSet } from "../messages_defaultSet";
import type { KcContext } from "../../KcContext";
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
import { is } from "tsafe/is";
import { Reflect } from "tsafe/Reflect";
import {
type LanguageTag as LanguageTag_defaultSet,
type MessageKey as MessageKey_defaultSet,
languageTags as languageTags_defaultSet
} from "../messages_defaultSet/types";
import type { GenericI18n_noJsx } from "./GenericI18n_noJsx";
export type KcContextLike = {
themeName: string;
locale?: {
currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[];
rtl?: boolean;
};
"x-keycloakify": {
messages: Record<string, string>;
};
};
assert<KcContext extends KcContextLike ? true : false>();
export type ReturnTypeOfCreateGetI18n<MessageKey_themeDefined extends string, LanguageTag_notInDefaultSet extends string> = {
getI18n: (params: { kcContext: KcContextLike }) => {
i18n: GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
prI18n_currentLanguage:
| Promise<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>>
| undefined;
};
ofTypeI18n: GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
};
export function createGetI18n<
ThemeName extends string = string,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never
>(params: {
extraLanguageTranslations: {
[languageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{ default: Record<MessageKey_defaultSet, string> }>;
};
};
messagesByLanguageTag_themeDefined: Partial<{
[languageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: {
[key in MessageKey_themeDefined]: string | Record<ThemeName, string>;
};
}>;
}): ReturnTypeOfCreateGetI18n<MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
const { extraLanguageTranslations, messagesByLanguageTag_themeDefined } = params;
Object.keys(extraLanguageTranslations).forEach(languageTag_notInDefaultSet => {
if (id<readonly string[]>(languageTags_defaultSet).includes(languageTag_notInDefaultSet)) {
throw new Error(
[
`Language "${languageTag_notInDefaultSet}" is already in the default set, you don't need to provide your own base translations for it`,
`If you want to override some translations for this language, you can use the "withCustomTranslations" method`
].join(" ")
);
}
});
type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet;
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
type I18n = GenericI18n_noJsx<MessageKey, LanguageTag>;
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
function getI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
use_cache: {
const cachedResult = cachedResultByKcContext.get(kcContext);
if (cachedResult === undefined) {
break use_cache;
}
return cachedResult;
}
{
const currentLanguageTag = kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG;
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
const isRtl = (() => {
const { rtl } = kcContext.locale ?? {};
if (rtl !== undefined) {
return rtl;
}
return [
/* spell-checker: disable */
// Common RTL languages
"ar", // Arabic
"fa", // Persian (Farsi)
"he", // Hebrew
"ur", // Urdu
"ps", // Pashto
"syr", // Syriac
"dv", // Divehi (Maldivian)
"ku", // Kurdish (Sorani)
"ug", // Uighur
"az", // Azerbaijani (Arabic script)
"sd", // Sindhi
// Less common RTL languages
"yi", // Yiddish
"ha", // Hausa (when written in Arabic script)
"ks", // Kashmiri (written in the Perso-Arabic script)
"bal", // Balochi (when written in Arabic script)
"khw", // Khowar (Chitrali)
"brh", // Brahui (when written in Arabic script)
"tmh", // Tamashek (some dialects use Arabic script)
"bgn", // Western Balochi
"arc", // Aramaic
"sam", // Samaritan Aramaic
"prd", // Parsi-Dari (a dialect of Persian)
"huz", // Hazaragi (a dialect of Persian)
"gbz", // Zaza (written in Arabic script in some areas)
"urj" // Urdu in Romanized script (not always RTL, but to account for edge cases)
/* spell-checker: enable */
].includes(currentLanguageTag);
})();
html.dir = isRtl ? "rtl" : "ltr";
}
const getLanguageLabel = (languageTag: LanguageTag) => {
form_user_added_languages: {
if (!(languageTag in extraLanguageTranslations)) {
break form_user_added_languages;
}
assert(is<Exclude<LanguageTag, LanguageTag_defaultSet>>(languageTag));
const entry = extraLanguageTranslations[languageTag];
return entry.label;
}
from_server: {
if (kcContext.locale === undefined) {
break from_server;
}
const supportedEntry = kcContext.locale.supported.find(entry => entry.languageTag === languageTag);
if (supportedEntry === undefined) {
break from_server;
}
// cspell: disable-next-line
// from "Espagnol (Español)" we want to extract "Español"
const match = supportedEntry.label.match(/[^(]+\(([^)]+)\)/);
if (match !== null) {
return match[1];
}
return supportedEntry.label;
}
// NOTE: This should never happen
return languageTag;
};
const currentLanguage: I18n["currentLanguage"] = (() => {
const languageTag = id<string>(kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG) as LanguageTag;
return {
languageTag,
label: getLanguageLabel(languageTag)
};
})();
const enabledLanguages: I18n["enabledLanguages"] = (() => {
const enabledLanguages: I18n["enabledLanguages"] = [];
if (kcContext.locale !== undefined) {
for (const entry of kcContext.locale.supported ?? []) {
const languageTag = id<string>(entry.languageTag) as LanguageTag;
enabledLanguages.push({
languageTag,
label: getLanguageLabel(languageTag),
href: entry.url
});
}
}
if (enabledLanguages.find(({ languageTag }) => languageTag === currentLanguage.languageTag) === undefined) {
enabledLanguages.push({
languageTag: currentLanguage.languageTag,
label: getLanguageLabel(currentLanguage.languageTag),
href: "#"
});
}
return enabledLanguages;
})();
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
themeName: kcContext.themeName,
messages_themeDefined:
messagesByLanguageTag_themeDefined[currentLanguage.languageTag] ??
messagesByLanguageTag_themeDefined[id<string>(FALLBACK_LANGUAGE_TAG) as LanguageTag] ??
(() => {
const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0];
if (firstLanguageTag === undefined) {
return undefined;
}
return messagesByLanguageTag_themeDefined[firstLanguageTag as LanguageTag];
})(),
messages_fromKcServer: kcContext["x-keycloakify"].messages
});
const isCurrentLanguageFallbackLanguage = currentLanguage.languageTag === FALLBACK_LANGUAGE_TAG;
const result: Result = {
i18n: {
currentLanguage,
enabledLanguages,
...createI18nTranslationFunctions({
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
}),
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
},
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
? undefined
: (async () => {
const messages_defaultSet_currentLanguage = await (async () => {
const currentLanguageTag = currentLanguage.languageTag;
const fromDefaultSet = await fetchMessages_defaultSet(currentLanguageTag);
const isEmpty = (() => {
for (let _key in fromDefaultSet) {
return false;
}
return true;
})();
if (isEmpty) {
assert(is<Exclude<LanguageTag, LanguageTag_defaultSet>>(currentLanguageTag));
const entry = extraLanguageTranslations[currentLanguageTag];
assert(entry !== undefined);
return entry.getMessages().then(({ default: messages }) => messages);
}
return fromDefaultSet;
})();
const i18n_currentLanguage: I18n = {
currentLanguage,
enabledLanguages,
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
isFetchingTranslations: false
};
// NOTE: This promise.resolve is just because without it we TypeScript
// gives a Variable 'result' is used before being assigned. error
await Promise.resolve().then(() => {
result.i18n = i18n_currentLanguage;
result.prI18n_currentLanguage = undefined;
});
return i18n_currentLanguage;
})()
};
cachedResultByKcContext.set(kcContext, result);
return result;
}
return {
getI18n,
ofTypeI18n: Reflect<I18n>()
};
}
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
themeName: string;
messages_themeDefined: Record<MessageKey_themeDefined, string | Record<string, string>> | undefined;
messages_fromKcServer: Record<string, string>;
}) {
const { themeName, messages_themeDefined, messages_fromKcServer } = params;
function createI18nTranslationFunctions(params: {
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, string>, "msgStr" | "advancedMsgStr"> {
const { messages_defaultSet_currentLanguage } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
const { key, args } = props;
const message =
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
(() => {
const messageOrMap = id<Record<string, string | Record<string, string> | undefined> | undefined>(messages_themeDefined)?.[key];
if (messageOrMap === undefined) {
return undefined;
}
if (typeof messageOrMap === "string") {
return messageOrMap;
}
const message = messageOrMap[themeName];
assert(message !== undefined, `No translation for theme variant "${themeName}" for key "${key}"`);
return message;
})() ??
id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];
if (message === undefined) {
return undefined;
}
const startIndex = message
.match(/{[0-9]+}/g)
?.map(g => g.match(/{([0-9]+)}/)![1])
.map(indexStr => parseInt(indexStr))
.sort((a, b) => a - b)[0];
if (startIndex === undefined) {
// No {0} in message (no arguments expected)
return message;
}
let messageWithArgsInjected = message;
args.forEach((arg, i) => {
if (arg === undefined) {
return;
}
messageWithArgsInjected = messageWithArgsInjected.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg);
});
return messageWithArgsInjected;
}
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
const { key, args } = props;
const match = key.match(/^\$\{(.+)\}$/);
return resolveMsg({ key: match !== null ? match[1] : key, args }) ?? key;
}
return {
msgStr: (key, ...args) => {
const resolvedMessage = resolveMsg({ key, args });
assert(resolvedMessage !== undefined, `Message with key "${key}" not found`);
return resolvedMessage;
},
advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args })
};
}
return { createI18nTranslationFunctions };
}

View File

@ -0,0 +1,117 @@
import type {
LanguageTag as LanguageTag_defaultSet,
MessageKey as MessageKey_defaultSet
} from "../messages_defaultSet/types";
import { type ReturnTypeOfCreateGetI18n, createGetI18n } from "./getI18n";
export type I18nBuilder<
ThemeName extends string = never,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never,
ExcludedMethod extends
| "withThemeName"
| "withExtraLanguages"
| "withCustomTranslations" = never
> = Omit<
{
withThemeName: <ThemeName extends string>() => I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withThemeName"
>;
withExtraLanguages: <
LanguageTag_notInDefaultSet extends string
>(extraLanguageTranslations: {
[LanguageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{
default: Record<MessageKey_defaultSet, string>;
}>;
};
}) => I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withExtraLanguages"
>;
withCustomTranslations: <MessageKey_themeDefined extends string>(
messagesByLanguageTag_themeDefined: Partial<{
[LanguageTag in
| LanguageTag_defaultSet
| LanguageTag_notInDefaultSet]: Record<
MessageKey_themeDefined,
string | Record<ThemeName, string>
>;
}>
) => I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withCustomTranslations"
>;
build: () => ReturnTypeOfCreateGetI18n<
MessageKey_themeDefined,
LanguageTag_notInDefaultSet
>;
},
ExcludedMethod
>;
function createI18nBuilder<
ThemeName extends string = never,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never
>(params: {
extraLanguageTranslations: {
[LanguageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{
default: Record<MessageKey_defaultSet, string>;
}>;
};
};
messagesByLanguageTag_themeDefined: Partial<{
[LanguageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: Record<
MessageKey_themeDefined,
string | Record<ThemeName, string>
>;
}>;
}): I18nBuilder<ThemeName, MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
const i18nBuilder: I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet
> = {
withThemeName: () =>
createI18nBuilder({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined as any
}),
withExtraLanguages: extraLanguageTranslations =>
createI18nBuilder({
extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined as any
}),
withCustomTranslations: messagesByLanguageTag_themeDefined =>
createI18nBuilder({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined
}),
build: () =>
createGetI18n({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined
})
};
return i18nBuilder;
}
export const i18nBuilder = createI18nBuilder({
extraLanguageTranslations: {},
messagesByLanguageTag_themeDefined: {}
});

View File

@ -0,0 +1,3 @@
export { i18nBuilder } from "./i18nBuilder";
export type { KcContextLike } from "./getI18n";
export type { MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";

View File

@ -1,95 +0,0 @@
import { useEffect, useState } from "react";
import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n";
import { GenericI18n } from "./GenericI18n";
import { Reflect } from "tsafe/Reflect";
export function createUseI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag: {
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
}) {
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
type I18n = GenericI18n<MessageKey>;
const { withJsx } = (() => {
const cache = new WeakMap<GenericI18n_noJsx<MessageKey>, GenericI18n<MessageKey>>();
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
const { htmlString, msgKey } = params;
return (
<div
data-kc-msg={msgKey}
dangerouslySetInnerHTML={{
__html: htmlString
}}
/>
);
}
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey>): I18n {
use_cache: {
const i18n = cache.get(i18n_noJsx);
if (i18n === undefined) {
break use_cache;
}
return i18n;
}
const i18n: I18n = {
...i18n_noJsx,
msg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.msgStr(msgKey, ...args), msgKey }),
advancedMsg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.advancedMsgStr(msgKey, ...args), msgKey })
};
cache.set(i18n_noJsx, i18n);
return i18n;
}
return { withJsx };
})();
add_style: {
const attributeName = "data-kc-i18n";
// Check if already exists in head
if (document.querySelector(`style[${attributeName}]`) !== null) {
break add_style;
}
const styleElement = document.createElement("style");
styleElement.attributes.setNamedItem(document.createAttribute(attributeName));
(styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement);
}
const { getI18n } = createGetI18n(messagesByLanguageTag);
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
const { kcContext } = params;
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(withJsx(i18n));
useEffect(() => {
let isActive = true;
prI18n_currentLanguage?.then(i18n => {
if (!isActive) {
return;
}
setI18n_toReturn(withJsx(i18n));
});
return () => {
isActive = false;
};
}, []);
return { i18n: i18n_toReturn };
}
return { useI18n, ofTypeI18n: Reflect<I18n>() };
}

View File

@ -0,0 +1,81 @@
import type { GenericI18n_noJsx } from "../noJsx/GenericI18n_noJsx";
import { assert, type Equals } from "tsafe/assert";
export type GenericI18n<MessageKey extends string, LanguageTag extends string> = {
currentLanguage: {
/**
* e.g: "en", "fr", "zh-CN"
*
* The current language
*/
languageTag: LanguageTag;
/**
* e.g: "English", "Français", "中文(简体)"
*
* The current language
*/
label: string;
};
/**
* Array of languages enabled on the realm.
*/
enabledLanguages: {
languageTag: LanguageTag;
label: string;
href: string;
}[];
/**
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
* "bar": "Bar {0}"
* }
* }
*
* msgStr("access-denied") === "Access denied"
* msgStr("not-a-message-key") Throws an error
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
* msgStr("${bar}", "<strong>c</strong>") === "Bar &lt;strong&gt;XXX&lt;/strong&gt;"
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
*/
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
/**
* This is meant to be used when the key argument is variable, something that might have been configured by the user
* in the Keycloak admin for example.
*
* Examples assuming currentLanguageTag === "en"
* {
* en: {
* "access-denied": "Access denied",
* }
* }
*
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
*/
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
/**
* Initially the messages are in english (fallback language).
* The translations in the current language are being fetched dynamically.
* This property is true while the translations are being fetched.
*/
isFetchingTranslations: boolean;
/**
* Same as msgStr but returns a JSX.Element with the html string rendered as html.
*/
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
/**
* Same as advancedMsgStr but returns a JSX.Element with the html string rendered as html.
*/
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
};
{
type A = Omit<GenericI18n<string, string>, "msg" | "advancedMsg">;
type B = GenericI18n_noJsx<string, string>;
assert<Equals<A, B>>;
}

View File

@ -0,0 +1,117 @@
import type {
LanguageTag as LanguageTag_defaultSet,
MessageKey as MessageKey_defaultSet
} from "../messages_defaultSet/types";
import { type ReturnTypeOfCreateUseI18n, createUseI18n } from "../withJsx/useI18n";
export type I18nBuilder<
ThemeName extends string = never,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never,
ExcludedMethod extends
| "withThemeName"
| "withExtraLanguages"
| "withCustomTranslations" = never
> = Omit<
{
withThemeName: <ThemeName extends string>() => I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withThemeName"
>;
withExtraLanguages: <
LanguageTag_notInDefaultSet extends string
>(extraLanguageTranslations: {
[LanguageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{
default: Record<MessageKey_defaultSet, string>;
}>;
};
}) => I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withExtraLanguages"
>;
withCustomTranslations: <MessageKey_themeDefined extends string>(
messagesByLanguageTag_themeDefined: Partial<{
[LanguageTag in
| LanguageTag_defaultSet
| LanguageTag_notInDefaultSet]: Record<
MessageKey_themeDefined,
string | Record<ThemeName, string>
>;
}>
) => I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet,
ExcludedMethod | "withCustomTranslations"
>;
build: () => ReturnTypeOfCreateUseI18n<
MessageKey_themeDefined,
LanguageTag_notInDefaultSet
>;
},
ExcludedMethod
>;
function createI18nBuilder<
ThemeName extends string = never,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never
>(params: {
extraLanguageTranslations: {
[LanguageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{
default: Record<MessageKey_defaultSet, string>;
}>;
};
};
messagesByLanguageTag_themeDefined: Partial<{
[LanguageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: Record<
MessageKey_themeDefined,
string | Record<ThemeName, string>
>;
}>;
}): I18nBuilder<ThemeName, MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
const i18nBuilder: I18nBuilder<
ThemeName,
MessageKey_themeDefined,
LanguageTag_notInDefaultSet
> = {
withThemeName: () =>
createI18nBuilder({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined as any
}),
withExtraLanguages: extraLanguageTranslations =>
createI18nBuilder({
extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined as any
}),
withCustomTranslations: messagesByLanguageTag_themeDefined =>
createI18nBuilder({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined
}),
build: () =>
createUseI18n({
extraLanguageTranslations: params.extraLanguageTranslations,
messagesByLanguageTag_themeDefined:
params.messagesByLanguageTag_themeDefined
})
};
return i18nBuilder;
}
export const i18nBuilder = createI18nBuilder({
extraLanguageTranslations: {},
messagesByLanguageTag_themeDefined: {}
});

View File

@ -0,0 +1,3 @@
export { i18nBuilder } from "./i18nBuilder";
export type { KcContextLike } from "./useI18n";
export type { MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";

View File

@ -0,0 +1,128 @@
import { useEffect, useState } from "react";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { createGetI18n, type KcContextLike } from "../noJsx/getI18n";
import type { GenericI18n_noJsx } from "../noJsx/GenericI18n_noJsx";
import { Reflect } from "tsafe/Reflect";
import type { GenericI18n } from "./GenericI18n";
import type { LanguageTag as LanguageTag_defaultSet, MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";
export type ReturnTypeOfCreateUseI18n<MessageKey_themeDefined extends string, LanguageTag_notInDefaultSet extends string> = {
useI18n: (params: { kcContext: KcContextLike }) => {
i18n: GenericI18n<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
};
ofTypeI18n: GenericI18n<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
};
export { KcContextLike };
export function createUseI18n<
ThemeName extends string = string,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never
>(params: {
extraLanguageTranslations: {
[languageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{ default: Record<MessageKey_defaultSet, string> }>;
};
};
messagesByLanguageTag_themeDefined: Partial<{
[languageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: {
[key in MessageKey_themeDefined]: string | Record<ThemeName, string>;
};
}>;
}): ReturnTypeOfCreateUseI18n<MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
const { extraLanguageTranslations, messagesByLanguageTag_themeDefined } = params;
type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet;
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
type I18n = GenericI18n<MessageKey, LanguageTag>;
type Result = { i18n: I18n };
const { withJsx } = (() => {
const cache = new WeakMap<GenericI18n_noJsx<MessageKey, LanguageTag>, GenericI18n<MessageKey, LanguageTag>>();
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
const { htmlString, msgKey } = params;
return (
<div
data-kc-msg={msgKey}
dangerouslySetInnerHTML={{
__html: kcSanitize(htmlString)
}}
/>
);
}
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey, LanguageTag>): I18n {
use_cache: {
const i18n = cache.get(i18n_noJsx);
if (i18n === undefined) {
break use_cache;
}
return i18n;
}
const i18n: I18n = {
...i18n_noJsx,
msg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.msgStr(msgKey, ...args), msgKey }),
advancedMsg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.advancedMsgStr(msgKey, ...args), msgKey })
};
cache.set(i18n_noJsx, i18n);
return i18n;
}
return { withJsx };
})();
add_style: {
const attributeName = "data-kc-i18n";
// Check if already exists in head
if (document.querySelector(`style[${attributeName}]`) !== null) {
break add_style;
}
const styleElement = document.createElement("style");
styleElement.attributes.setNamedItem(document.createAttribute(attributeName));
styleElement.textContent = `[data-kc-msg] { display: inline-block; }`;
document.head.prepend(styleElement);
}
const { getI18n } = createGetI18n({ extraLanguageTranslations, messagesByLanguageTag_themeDefined });
function useI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(withJsx(i18n));
useEffect(() => {
let isActive = true;
prI18n_currentLanguage?.then(i18n => {
if (!isActive) {
return;
}
setI18n_toReturn(withJsx(i18n));
});
return () => {
isActive = false;
};
}, []);
return { i18n: i18n_toReturn };
}
return { useI18n, ofTypeI18n: Reflect<I18n>() };
}

View File

@ -1,3 +1,3 @@
export type { ExtendKcContext, Attribute } from "keycloakify/login/KcContext";
export type { ClassKey } from "keycloakify/login/TemplateProps";
export { createUseI18n } from "keycloakify/login/i18n";
export { i18nBuilder, type MessageKey_defaultSet } from "keycloakify/login/i18n";

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,109 @@
import { assert } from "keycloakify/tools/assert";
let cleanup: (() => void) | undefined;
const handledElements = new WeakSet<HTMLElement>();
const KC_NUMBER_UNFORMAT = "kcNumberUnFormat";
const SELECTOR = `input[data-${KC_NUMBER_UNFORMAT}]`;
export function unFormatNumberOnSubmit() {
cleanup?.();
const handleElement = (element: HTMLInputElement) => {
if (handledElements.has(element)) {
return;
}
const form = element.closest("form");
if (form === null) {
return;
}
form.addEventListener("submit", () => {
const rawFormat = element.getAttribute(`data-${KC_NUMBER_UNFORMAT}`);
if (rawFormat) {
element.value = formatNumber(element.value, rawFormat);
}
});
handledElements.add(element);
};
document.querySelectorAll(SELECTOR).forEach(element => {
assert(element instanceof HTMLInputElement);
handleElement(element);
});
const observer = new MutationObserver(mutationsList => {
for (const mutation of mutationsList) {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = (node as HTMLElement).querySelector(SELECTOR);
if (element !== null) {
assert(element instanceof HTMLInputElement);
handleElement(element);
}
}
});
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
cleanup = () => observer.disconnect();
}
// NOTE: Keycloak code
const formatNumber = (input: string, format: string) => {
if (!input) {
return "";
}
// array holding the patterns for the number of expected digits in each part
const digitPattern = format.match(/{\d+}/g);
if (!digitPattern) {
return "";
}
// calculate the maximum size of the given pattern based on the sum of the expected digits
const maxSize = digitPattern.reduce(
(total, p) => total + parseInt(p.replace("{", "").replace("}", "")),
0
);
// keep only digits
let rawValue = input.replace(/\D+/g, "");
// make sure the value is a number
//@ts-expect-error
if (parseInt(rawValue) != rawValue) {
return "";
}
// make sure the number of digits does not exceed the maximum size
if (rawValue.length > maxSize) {
rawValue = rawValue.substring(0, maxSize);
}
// build the regex based based on the expected digits in each part
const formatter = digitPattern.reduce((result, p) => result + `(\\d${p})`, "^");
// if the current digits match the pattern we have each group of digits in an array
let digits = new RegExp(formatter).exec(rawValue);
// no match, return the raw value without any format
if (!digits) {
return input;
}
let result = format;
// finally format the current digits accordingly to the given format
for (let i = 0; i < digitPattern.length; i++) {
result = result.replace(digitPattern[i], digits[i + 1]);
}
return result;
};

View File

@ -1,5 +1,130 @@
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
import type { ClassKey } from "keycloakify/login/TemplateProps";
export type ClassKey =
| "kcBodyClass"
| "kcHeaderWrapperClass"
| "kcLocaleWrapperClass"
| "kcInfoAreaWrapperClass"
| "kcFormButtonsWrapperClass"
| "kcFormOptionsWrapperClass"
| "kcCheckboxInputClass"
| "kcLocaleDropDownClass"
| "kcLocaleListItemClass"
| "kcContentWrapperClass"
| "kcLogoIdP-facebook"
| "kcAuthenticatorOTPClass"
| "kcLogoIdP-bitbucket"
| "kcAuthenticatorWebAuthnClass"
| "kcWebAuthnDefaultIcon"
| "kcLogoIdP-stackoverflow"
| "kcSelectAuthListItemClass"
| "kcLogoIdP-microsoft"
| "kcLoginOTPListItemHeaderClass"
| "kcLocaleItemClass"
| "kcLoginOTPListItemIconBodyClass"
| "kcInputHelperTextAfterClass"
| "kcFormClass"
| "kcSelectAuthListClass"
| "kcInputClassRadioCheckboxLabelDisabled"
| "kcSelectAuthListItemIconClass"
| "kcRecoveryCodesWarning"
| "kcFormSettingClass"
| "kcWebAuthnBLE"
| "kcInputWrapperClass"
| "kcSelectAuthListItemArrowIconClass"
| "kcFeedbackAreaClass"
| "kcFormPasswordVisibilityButtonClass"
| "kcLogoIdP-google"
| "kcCheckLabelClass"
| "kcSelectAuthListItemFillClass"
| "kcAuthenticatorDefaultClass"
| "kcLogoIdP-gitlab"
| "kcFormAreaClass"
| "kcFormButtonsClass"
| "kcInputClassRadioLabel"
| "kcAuthenticatorWebAuthnPasswordlessClass"
| "kcSelectAuthListItemHeadingClass"
| "kcInfoAreaClass"
| "kcLogoLink"
| "kcContainerClass"
| "kcSelectAuthListItemTitle"
| "kcHtmlClass"
| "kcLoginOTPListItemTitleClass"
| "kcLogoIdP-openshift-v4"
| "kcWebAuthnUnknownIcon"
| "kcFormSocialAccountNameClass"
| "kcLogoIdP-openshift-v3"
| "kcLoginOTPListInputClass"
| "kcWebAuthnUSB"
| "kcInputClassRadio"
| "kcWebAuthnKeyIcon"
| "kcFeedbackInfoIcon"
| "kcCommonLogoIdP"
| "kcRecoveryCodesActions"
| "kcFormGroupHeader"
| "kcFormSocialAccountSectionClass"
| "kcLogoIdP-instagram"
| "kcAlertClass"
| "kcHeaderClass"
| "kcLabelWrapperClass"
| "kcFormPasswordVisibilityIconShow"
| "kcFormSocialAccountLinkClass"
| "kcLocaleMainClass"
| "kcInputGroup"
| "kcTextareaClass"
| "kcButtonBlockClass"
| "kcButtonClass"
| "kcWebAuthnNFC"
| "kcLocaleClass"
| "kcInputClassCheckboxInput"
| "kcFeedbackErrorIcon"
| "kcInputLargeClass"
| "kcInputErrorMessageClass"
| "kcRecoveryCodesList"
| "kcFormSocialAccountListClass"
| "kcAlertTitleClass"
| "kcAuthenticatorPasswordClass"
| "kcCheckInputClass"
| "kcLogoIdP-linkedin"
| "kcLogoIdP-twitter"
| "kcFeedbackWarningIcon"
| "kcResetFlowIcon"
| "kcSelectAuthListItemIconPropertyClass"
| "kcFeedbackSuccessIcon"
| "kcLoginOTPListClass"
| "kcSrOnlyClass"
| "kcFormSocialAccountListGridClass"
| "kcButtonDefaultClass"
| "kcFormGroupErrorClass"
| "kcSelectAuthListItemDescriptionClass"
| "kcSelectAuthListItemBodyClass"
| "kcWebAuthnInternal"
| "kcSelectAuthListItemArrowClass"
| "kcCheckClass"
| "kcContentClass"
| "kcLogoClass"
| "kcLoginOTPListItemIconClass"
| "kcLoginClass"
| "kcSignUpClass"
| "kcButtonLargeClass"
| "kcFormCardClass"
| "kcLocaleListClass"
| "kcInputClass"
| "kcFormGroupClass"
| "kcLogoIdP-paypal"
| "kcInputClassCheckbox"
| "kcRecoveryCodesConfirmation"
| "kcFormPasswordVisibilityIconHide"
| "kcInputClassRadioInput"
| "kcFormSocialAccountListButtonClass"
| "kcInputClassCheckboxLabel"
| "kcFormOptionsClass"
| "kcFormHeaderClass"
| "kcFormSocialAccountGridItem"
| "kcButtonPrimaryClass"
| "kcInputHelperTextBeforeClass"
| "kcLogoIdP-github"
| "kcLabelClass";
export const { getKcClsx } = createGetKcClsx<ClassKey>({
defaultClasses: {
@ -138,6 +263,4 @@ export const { getKcClsx } = createGetKcClsx<ClassKey>({
}
});
export type { ClassKey };
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
export default function Code(props: PageProps<Extract<KcContext, { pageId: "code.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -30,7 +31,14 @@ export default function Code(props: PageProps<Extract<KcContext, { pageId: "code
<input id="code" className={kcClsx("kcTextareaClass")} defaultValue={code.code} />
</>
) : (
<p id="error">{code.error}</p>
code.error && (
<p
id="error"
dangerouslySetInnerHTML={{
__html: kcSanitize(code.error)
}}
/>
)
)}
</div>
</Template>

View File

@ -1,4 +1,5 @@
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -19,7 +20,7 @@ export default function Error(props: PageProps<Extract<KcContext, { pageId: "err
headerNode={msg("errorTitle")}
>
<div id="kc-error-message">
<p className="instruction" dangerouslySetInnerHTML={{ __html: message.summary }} />
<p className="instruction" dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />
{!skipLink && client !== undefined && client.baseUrl !== undefined && (
<p>
<a id="backToApplication" href={client.baseUrl}>

View File

@ -1,4 +1,5 @@
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -19,7 +20,7 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
headerNode={
<span
dangerouslySetInnerHTML={{
__html: messageHeader ?? message.summary
__html: kcSanitize(messageHeader ?? message.summary)
}}
/>
}
@ -28,19 +29,21 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
<p
className="instruction"
dangerouslySetInnerHTML={{
__html: (() => {
let html = message.summary;
__html: kcSanitize(
(() => {
let html = message.summary;
if (requiredActions) {
html += "<b>";
if (requiredActions) {
html += "<b>";
html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(", ");
html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(", ");
html += "</b>";
}
html += "</b>";
}
return html;
})()
return html;
})()
)
}}
/>
{(() => {

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useReducer } from "react";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { assert } from "keycloakify/tools/assert";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
@ -62,7 +63,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
{p.iconClasses && <i className={clsx(kcClsx("kcCommonLogoIdP"), p.iconClasses)} aria-hidden="true"></i>}
<span
className={clsx(kcClsx("kcFormSocialAccountNameClass"), p.iconClasses && "kc-social-icon-text")}
dangerouslySetInnerHTML={{ __html: p.displayName }}
dangerouslySetInnerHTML={{ __html: kcSanitize(p.displayName) }}
></span>
</a>
</li>
@ -111,7 +112,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.getFirstError("username", "password")
__html: kcSanitize(messagesPerField.getFirstError("username", "password"))
}}
/>
)}
@ -139,7 +140,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.getFirstError("username", "password")
__html: kcSanitize(messagesPerField.getFirstError("username", "password"))
}}
/>
)}

View File

@ -1,4 +1,5 @@
import { getKcClsx, KcClsx } from "keycloakify/login/lib/kcClsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -117,7 +118,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("totp")
__html: kcSanitize(messagesPerField.get("totp"))
}}
/>
)}
@ -148,7 +149,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("userLabel")
__html: kcSanitize(messagesPerField.get("userLabel"))
}}
/>
)}

View File

@ -1,5 +1,6 @@
import { Fragment } from "react";
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -75,7 +76,7 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("totp")
__html: kcSanitize(messagesPerField.get("totp"))
}}
/>
)}

View File

@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { assert } from "keycloakify/tools/assert";
import { KcContext } from "keycloakify/login/KcContext/KcContext";
import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom";
type KcContextLike = {
url: {
@ -67,6 +68,12 @@ export function useScript(params: { authButtonId: string; kcContext: KcContextLi
return;
}
insertScriptTags();
(async () => {
await waitForElementMountedOnDom({
elementId: authButtonId
});
insertScriptTags();
})();
}, [isFetchingTranslations]);
}

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useReducer } from "react";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { clsx } from "keycloakify/tools/clsx";
import { assert } from "keycloakify/tools/assert";
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
@ -65,7 +66,7 @@ export default function LoginPassword(props: PageProps<Extract<KcContext, { page
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("password")
__html: kcSanitize(messagesPerField.get("password"))
}}
/>
)}

View File

@ -1,5 +1,6 @@
import { useEffect } from "react";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom";
type I18nLike = {
msgStr: (key: "recovery-codes-download-file-header" | "recovery-codes-download-file-description" | "recovery-codes-download-file-date") => string;
@ -137,6 +138,12 @@ export function useScript(params: { olRecoveryCodesListId: string; i18n: I18nLik
return;
}
insertScriptTags();
(async () => {
await waitForElementMountedOnDom({
elementId: olRecoveryCodesListId
});
insertScriptTags();
})();
}, [isFetchingTranslations]);
}

View File

@ -1,4 +1,5 @@
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -48,7 +49,7 @@ export default function LoginRecoveryAuthnCodeInput(props: PageProps<Extract<KcC
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("recoveryCodeInput")
__html: kcSanitize(messagesPerField.get("recoveryCodeInput"))
}}
/>
)}

View File

@ -1,4 +1,5 @@
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -53,7 +54,7 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("username")
__html: kcSanitize(messagesPerField.get("username"))
}}
/>
)}

View File

@ -1,4 +1,5 @@
import { useEffect, useReducer } from "react";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { assert } from "keycloakify/tools/assert";
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
@ -52,7 +53,7 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("password")
__html: kcSanitize(messagesPerField.get("password"))
}}
/>
)}
@ -84,7 +85,7 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("password-confirm")
__html: kcSanitize(messagesPerField.get("password-confirm"))
}}
/>
)}

View File

@ -1,5 +1,6 @@
import { useState } from "react";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
import { clsx } from "keycloakify/tools/clsx";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps";
@ -145,7 +146,7 @@ function TermsAcceptance(props: {
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: messagesPerField.get("termsAccepted")
__html: kcSanitize(messagesPerField.get("termsAccepted"))
}}
/>
</div>

View File

@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { assert } from "keycloakify/tools/assert";
import { KcContext } from "keycloakify/login/KcContext/KcContext";
import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom";
type KcContextLike = {
url: {
@ -59,6 +60,12 @@ export function useScript(params: { authButtonId: string; kcContext: KcContextLi
return;
}
insertScriptTags();
(async () => {
await waitForElementMountedOnDom({
elementId: authButtonId
});
insertScriptTags();
})();
}, [isFetchingTranslations]);
}

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