Compare commits
307 Commits
Author | SHA1 | Date | |
---|---|---|---|
232be50225 | |||
a00bb0c4db | |||
5517d6baf4 | |||
e2975503a4 | |||
1231c92198 | |||
64ca0bc0ca | |||
2bceb9385c | |||
954bc43c22 | |||
8c99aa4b9d | |||
884195d30d | |||
17dd726158 | |||
7ee30b6a42 | |||
397f8133bf | |||
c9abc6dc5c | |||
e78eafd1f1 | |||
e50f2bd692 | |||
ed0428bd55 | |||
2a126d65c5 | |||
30149ff1f2 | |||
32f471624a | |||
7f5eabb639 | |||
32fb1e2f71 | |||
7c3c6d3643 | |||
b6d2154d56 | |||
b8d4daf4c1 | |||
c03623875a | |||
c423e4cacc | |||
c593f5cb97 | |||
2ad36a8137 | |||
bccaddc2de | |||
97c12c8a12 | |||
b349a819ba | |||
792d4262c8 | |||
37a9046a40 | |||
5ad29d9f43 | |||
645031543e | |||
b43c02f279 | |||
63877d53be | |||
79a580b4a5 | |||
994f1f8d3d | |||
a73281d46d | |||
a60a0d0696 | |||
a2ea81b3b8 | |||
a0461e3ef0 | |||
93fcf96cde | |||
d7455fd100 | |||
af7a45d125 | |||
5357626317 | |||
552c95c59e | |||
50590697ca | |||
e261736fa3 | |||
db37320280 | |||
263f55fdd3 | |||
2b7f8a24a3 | |||
b0aa0feab5 | |||
0e93d4ed09 | |||
dc4eac1a04 | |||
53a427d190 | |||
ae969f91ac | |||
c83319d6f3 | |||
329b4cb0fb | |||
533f5992d1 | |||
cb103cc3e2 | |||
afdf89fb12 | |||
26a87b8eaa | |||
25d31463f4 | |||
2542c38c9b | |||
7326038424 | |||
12e632d221 | |||
3fc2108214 | |||
babbe39494 | |||
32b4585e39 | |||
43469a869c | |||
6f823e6478 | |||
e33693e20e | |||
ad3f091d4a | |||
3ff01d186d | |||
0cf8caa53b | |||
25920c208d | |||
19da96113f | |||
6e584e809e | |||
4185188a5b | |||
4273322ed5 | |||
ba0532c95d | |||
3a2fe597ba | |||
dda77952a0 | |||
d2e518d96b | |||
f3a97b2538 | |||
cacd017244 | |||
f5b15a5ef6 | |||
de620dca56 | |||
8decf4a3c9 | |||
831326952b | |||
27da578446 | |||
2c1cca168f | |||
e498fb784b | |||
2917719315 | |||
9ed90995e4 | |||
0f99bb5bdc | |||
1f4d4473e4 | |||
5332001ff4 | |||
22241fd7ad | |||
ddeade9775 | |||
f1cb165bdd | |||
9873353990 | |||
b879569b81 | |||
c3e821088b | |||
dc4f386e7a | |||
a40810b364 | |||
1690629717 | |||
9a6a71c8bc | |||
d626699f08 | |||
6aa60e685b | |||
9910762abc | |||
182fb430f1 | |||
bda20e2fbe | |||
bc586eceef | |||
128b27221a | |||
2dfb4eda9d | |||
fed6af4dfe | |||
c4ee6cd85c | |||
8fc307bd8d | |||
9e9ffcd586 | |||
49b064b5f2 | |||
ef6f5a4c23 | |||
e92562fd44 | |||
fe65ddb5f8 | |||
ffd405c6db | |||
9e41868e0d | |||
ca6accc889 | |||
dfe2e1562a | |||
ab43bb73d7 | |||
22b0b95e54 | |||
290ad8b592 | |||
d5519dbb55 | |||
4de9e059e9 | |||
e573aff6ae | |||
908e083dee | |||
ec29724997 | |||
88756e9807 | |||
80d8a0c4e3 | |||
7241f0c741 | |||
8565eb3fb8 | |||
87198f6e56 | |||
fa934da442 | |||
6c4dc711d2 | |||
1f2a755a97 | |||
a0e3dc163a | |||
810dc6ceb5 | |||
7203c742be | |||
2fd04cfb61 | |||
9c44d13f73 | |||
d6436a58a2 | |||
613167f3a6 | |||
ab0c281d98 | |||
c84dc281a2 | |||
835833a61b | |||
9af542ec89 | |||
06e33196bb | |||
36dd324139 | |||
52d4fe920c | |||
0d090d50d4 | |||
e57232edde | |||
dfe1e7ddd1 | |||
5ffc42c9db | |||
c63648a1b0 | |||
80fd4095c4 | |||
31d7a938f2 | |||
ee1b6868f8 | |||
7c7e5544e4 | |||
06fe26fbe7 | |||
c932c7d8f6 | |||
d56c536446 | |||
f5a9a28124 | |||
b86039536e | |||
59c4675e8a | |||
fbf6a329df | |||
ddec3118a4 | |||
94e2786297 | |||
ecfdff5454 | |||
c598a58ec9 | |||
3e38beb190 | |||
61a86e8e82 | |||
9c8e127fa0 | |||
eb23b40e5c | |||
9e19aafcd0 | |||
4cdf26b6f9 | |||
88de58cc22 | |||
f6b48c88b9 | |||
12534e57ad | |||
52e33bba2d | |||
d63e5f4e54 | |||
8d31866a0b | |||
7d818f217a | |||
7156665684 | |||
5045c5e8bf | |||
9de2ed9eaf | |||
096cf7a570 | |||
a04f07d149 | |||
63775b2866 | |||
e8609de7b4 | |||
e62aa89d72 | |||
77f12a940d | |||
0fe49e3d6e | |||
881386a123 | |||
7b9aec4ed0 | |||
cf18f9d06c | |||
052936f769 | |||
590de7a67b | |||
7f608ad8ad | |||
35b012b937 | |||
e3bd7f3bc5 | |||
e14f187fc0 | |||
da495b90ae | |||
8d9b80f549 | |||
2e9da33622 | |||
6f416ad335 | |||
4e982ee898 | |||
bcb514ae9c | |||
cfdad8d71d | |||
39ad1eb8d1 | |||
3d1d2e316b | |||
dd217e8a46 | |||
1339a96ea4 | |||
616e834c90 | |||
80eaa77acc | |||
ce3135c83b | |||
09abc73068 | |||
037d623550 | |||
8c8d2fd6a8 | |||
153a99d63f | |||
939e3ca7ea | |||
a0dc7eeb7c | |||
c21d072231 | |||
2e10ec8073 | |||
1177d6770c | |||
d492a393fe | |||
77952337c5 | |||
6716fcb881 | |||
302fe8d7cd | |||
2ea5e34e81 | |||
d7103b1ad9 | |||
9f8a36fe93 | |||
47ca811878 | |||
8cacb21f1b | |||
a0c95207cf | |||
da3023cf5e | |||
5892cf2ba7 | |||
c9d7fc1b6e | |||
94779c3476 | |||
802a6ab5ec | |||
04307c8226 | |||
ff6b91b801 | |||
c8ca598465 | |||
9444b897ee | |||
3d1951b72c | |||
acc27ae448 | |||
e6993214ff | |||
2f02a4379c | |||
b57d014e9a | |||
f57f311aab | |||
4f11415107 | |||
346fd7175f | |||
7c02d77057 | |||
d3fd4b6bbf | |||
43ef527810 | |||
a6032a1387 | |||
23179cac53 | |||
954c3319bb | |||
eb6ec0275d | |||
890f8bc2d5 | |||
26b8dd9cda | |||
c07af8491c | |||
10d4da9fbf | |||
95e861099f | |||
6dc51dfab3 | |||
ddb0af1dcb | |||
b6e9043d91 | |||
7c553ee10d | |||
2a6b14adc6 | |||
159a5f60d0 | |||
08f03b3118 | |||
f137960f96 | |||
e5ab46727a | |||
8d2679b76e | |||
b0b6b994ed | |||
bb163132fe | |||
439bed2f24 | |||
5a233d8878 | |||
20cdbb6185 | |||
b3c4208e44 | |||
8623037224 | |||
e8d3d3d741 | |||
cc700f0ba0 | |||
801a5cce17 | |||
2a3ad58c18 | |||
969744f4cb | |||
40ebbdebeb | |||
eb64886dcf | |||
81fc9d57bd | |||
66b480f837 | |||
7e6a84ce19 | |||
68e7642827 | |||
b37c7ccc8a | |||
b7c9ba8ffd | |||
c8a31c4b6a | |||
aad89a2001 |
@ -259,6 +259,56 @@
|
|||||||
"code",
|
"code",
|
||||||
"doc"
|
"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,
|
"contributorsPerLine": 7,
|
||||||
|
39
.github/workflows/ci.yaml
vendored
39
.github/workflows/ci.yaml
vendored
@ -3,7 +3,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- v10
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@ -36,21 +35,21 @@ jobs:
|
|||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: npm run test
|
- run: npm run test
|
||||||
|
|
||||||
#storybook:
|
storybook:
|
||||||
# runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# if: github.event_name == 'push'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
# needs: test
|
needs: test
|
||||||
# steps:
|
steps:
|
||||||
# - uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
# - uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
# with:
|
with:
|
||||||
# node-version: '18'
|
node-version: '18'
|
||||||
# - uses: bahmutov/npm-install@v1
|
- uses: bahmutov/npm-install@v1
|
||||||
# - run: npm run build-storybook
|
- run: npm run build-storybook
|
||||||
# - run: git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${{github.repository}}.git
|
- run: git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${{github.repository}}.git
|
||||||
# env:
|
env:
|
||||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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:
|
check_if_version_upgraded:
|
||||||
name: Check if version upgrade
|
name: Check if version upgrade
|
||||||
@ -96,7 +95,6 @@ jobs:
|
|||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: ${{ needs.check_if_version_upgraded.outputs.is_pre_release == 'true' }}
|
prerelease: ${{ needs.check_if_version_upgraded.outputs.is_pre_release == 'true' }}
|
||||||
make_latest: false
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@ -114,7 +112,7 @@ jobs:
|
|||||||
registry-url: https://registry.npmjs.org/
|
registry-url: https://registry.npmjs.org/
|
||||||
- uses: bahmutov/npm-install@v1
|
- uses: bahmutov/npm-install@v1
|
||||||
- run: npm run build
|
- 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:
|
env:
|
||||||
DRY_RUN: "0"
|
DRY_RUN: "0"
|
||||||
- uses: garronej/ts-ci@v2.1.2
|
- uses: garronej/ts-ci@v2.1.2
|
||||||
@ -130,7 +128,10 @@ jobs:
|
|||||||
echo "Can't publish on NPM, You must first create a secret called NPM_TOKEN that contains your NPM auth token. https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets"
|
echo "Can't publish on NPM, You must first create a secret called NPM_TOKEN that contains your NPM auth token. https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets"
|
||||||
false
|
false
|
||||||
fi
|
fi
|
||||||
EXTRA_ARGS="--tag keycloakify_v10"
|
EXTRA_ARGS=""
|
||||||
|
if [ "$IS_PRE_RELEASE" = "true" ]; then
|
||||||
|
EXTRA_ARGS="--tag next"
|
||||||
|
fi
|
||||||
npm publish $EXTRA_ARGS
|
npm publish $EXTRA_ARGS
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -49,7 +49,7 @@ jspm_packages
|
|||||||
.idea
|
.idea
|
||||||
|
|
||||||
/src/login/i18n/messages_defaultSet/
|
/src/login/i18n/messages_defaultSet/
|
||||||
/src/account/i18n/messages_defaultSet/
|
/src/account/i18n/
|
||||||
|
|
||||||
# VS Code devcontainers
|
# VS Code devcontainers
|
||||||
.devcontainer
|
.devcontainer
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
Looking to contribute? Thank you! PR are more than welcome.
|
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.
|
||||||
|
44
README.md
44
README.md
@ -41,23 +41,50 @@
|
|||||||
<img width="400" src="https://github.com/user-attachments/assets/6bf3bef9-00b0-4460-97b9-0d2da8500798">
|
<img width="400" src="https://github.com/user-attachments/assets/6bf3bef9-00b0-4460-97b9-0d2da8500798">
|
||||||
</p>
|
</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
|
## Sponsors
|
||||||
|
|
||||||
Friends for the project, we trust and recommend their services.
|
Project backers, we trust and recommend their services.
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
</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">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -82,7 +109,7 @@ Friends for the project, we trust and recommend their services.
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
<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/>
|
<br/>
|
||||||
Use code <code>keycloakify5</code> at checkout for a 5% discount.
|
Use code <code>keycloakify5</code> at checkout for a 5% discount.
|
||||||
</p>
|
</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/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>
|
<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>
|
||||||
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
29
package.json
29
package.json
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "keycloakify",
|
"name": "keycloakify",
|
||||||
"version": "10.1.6",
|
"version": "11.3.24",
|
||||||
"description": "Create Keycloak themes using React",
|
"description": "Framework to create custom Keycloak UIs",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git://github.com/keycloakify/keycloakify.git"
|
"url": "git://github.com/keycloakify/keycloakify.git"
|
||||||
@ -38,12 +38,14 @@
|
|||||||
"dist/",
|
"dist/",
|
||||||
"!dist/tsconfig.tsbuildinfo",
|
"!dist/tsconfig.tsbuildinfo",
|
||||||
"!dist/bin/",
|
"!dist/bin/",
|
||||||
|
"dist/bin/**/*.d.ts",
|
||||||
"dist/bin/main.js",
|
"dist/bin/main.js",
|
||||||
"dist/bin/*.index.js",
|
"dist/bin/*.index.js",
|
||||||
"dist/bin/*.node",
|
"dist/bin/*.node",
|
||||||
"dist/bin/shared/constants.js",
|
"dist/bin/shared/constants.js",
|
||||||
"dist/bin/shared/*.d.ts",
|
"dist/bin/shared/constants.js.map",
|
||||||
"dist/bin/shared/*.js.map",
|
"dist/bin/shared/customHandler.js",
|
||||||
|
"dist/bin/shared/customHandler.js.map",
|
||||||
"!dist/vite-plugin/",
|
"!dist/vite-plugin/",
|
||||||
"dist/vite-plugin/index.js",
|
"dist/vite-plugin/index.js",
|
||||||
"dist/vite-plugin/index.d.ts",
|
"dist/vite-plugin/index.d.ts",
|
||||||
@ -62,12 +64,13 @@
|
|||||||
],
|
],
|
||||||
"homepage": "https://www.keycloakify.dev",
|
"homepage": "https://www.keycloakify.dev",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tsafe": "^1.6.6"
|
"tsafe": "^1.7.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.5",
|
"@babel/core": "^7.24.5",
|
||||||
"@babel/generator": "^7.24.5",
|
"@babel/generator": "^7.24.5",
|
||||||
"@babel/parser": "^7.24.5",
|
"@babel/parser": "^7.24.5",
|
||||||
|
"@babel/preset-env": "7.24.8",
|
||||||
"@babel/types": "^7.24.5",
|
"@babel/types": "^7.24.5",
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.11.4",
|
||||||
"@octokit/rest": "^20.1.1",
|
"@octokit/rest": "^20.1.1",
|
||||||
@ -75,20 +78,27 @@
|
|||||||
"@storybook/builder-webpack5": "^6.5.13",
|
"@storybook/builder-webpack5": "^6.5.13",
|
||||||
"@storybook/manager-webpack5": "^6.5.13",
|
"@storybook/manager-webpack5": "^6.5.13",
|
||||||
"@storybook/react": "^6.5.13",
|
"@storybook/react": "^6.5.13",
|
||||||
"eslint-plugin-storybook": "^0.6.7",
|
|
||||||
"@types/babel__generator": "^7.6.4",
|
"@types/babel__generator": "^7.6.4",
|
||||||
|
"@types/dompurify": "^2.0.0",
|
||||||
"@types/make-fetch-happen": "^10.0.1",
|
"@types/make-fetch-happen": "^10.0.1",
|
||||||
"@types/minimist": "^1.2.2",
|
"@types/minimist": "^1.2.2",
|
||||||
"@types/node": "^18.15.3",
|
"@types/node": "^18.15.3",
|
||||||
|
"@types/properties-parser": "^0.3.3",
|
||||||
"@types/react": "^18.0.35",
|
"@types/react": "^18.0.35",
|
||||||
"@types/react-dom": "^18.0.11",
|
"@types/react-dom": "^18.0.11",
|
||||||
"@types/yauzl": "^2.10.3",
|
"@types/yauzl": "^2.10.3",
|
||||||
"@vercel/ncc": "^0.38.1",
|
"@vercel/ncc": "^0.38.1",
|
||||||
|
"babel-loader": "9.1.3",
|
||||||
"chalk": "^4.1.2",
|
"chalk": "^4.1.2",
|
||||||
"cheerio": "1.0.0-rc.12",
|
"cheerio": "1.0.0-rc.12",
|
||||||
"chokidar-cli": "^3.0.0",
|
"chokidar-cli": "^3.0.0",
|
||||||
"cli-select": "^1.1.2",
|
"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",
|
"husky": "^4.3.8",
|
||||||
|
"isomorphic-dompurify": "^2.15.0",
|
||||||
"lint-staged": "^11.0.0",
|
"lint-staged": "^11.0.0",
|
||||||
"magic-string": "^0.30.7",
|
"magic-string": "^0.30.7",
|
||||||
"make-fetch-happen": "^11.0.3",
|
"make-fetch-happen": "^11.0.3",
|
||||||
@ -103,12 +113,13 @@
|
|||||||
"termost": "^v0.12.1",
|
"termost": "^v0.12.1",
|
||||||
"tsc-alias": "^1.8.10",
|
"tsc-alias": "^1.8.10",
|
||||||
"tss-react": "^4.9.10",
|
"tss-react": "^4.9.10",
|
||||||
|
"tsx": "^4.15.5",
|
||||||
"typescript": "^4.9.4",
|
"typescript": "^4.9.4",
|
||||||
"vite": "^5.2.11",
|
"vite": "^5.2.11",
|
||||||
"vitest": "^1.6.0",
|
"vitest": "^1.6.0",
|
||||||
|
"webpack": "5.93.0",
|
||||||
|
"webpack-cli": "5.1.4",
|
||||||
"yauzl": "^2.10.0",
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.17.10",
|
"zod": "^3.17.10"
|
||||||
"evt": "^2.5.7",
|
|
||||||
"tsx": "^4.15.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,4 @@
|
|||||||
import * as child_process from "child_process";
|
import { run } from "./shared/run";
|
||||||
|
|
||||||
run("yarn build");
|
run("yarn build");
|
||||||
run("npx build-storybook");
|
run("npx build-storybook");
|
||||||
|
|
||||||
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
|
|
||||||
console.log(`$ ${command}`);
|
|
||||||
|
|
||||||
child_process.execSync(command, { stdio: "inherit", ...options });
|
|
||||||
}
|
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import * as child_process from "child_process";
|
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { assert } from "tsafe/assert";
|
import { assert } from "tsafe/assert";
|
||||||
@ -6,6 +5,8 @@ import { transformCodebase } from "../../src/bin/tools/transformCodebase";
|
|||||||
import { createPublicKeycloakifyDevResourcesDir } from "./createPublicKeycloakifyDevResourcesDir";
|
import { createPublicKeycloakifyDevResourcesDir } from "./createPublicKeycloakifyDevResourcesDir";
|
||||||
import { createAccountV1Dir } from "./createAccountV1Dir";
|
import { createAccountV1Dir } from "./createAccountV1Dir";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import { run } from "../shared/run";
|
||||||
|
import { vendorFrontendDependencies } from "./vendorFrontendDependencies";
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
console.log(chalk.cyan("Building Keycloakify..."));
|
console.log(chalk.cyan("Building Keycloakify..."));
|
||||||
@ -39,7 +40,9 @@ import chalk from "chalk";
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
run(`npx ncc build ${join("dist", "bin", "main.js")} -o ${join("dist", "ncc_out")}`);
|
run(
|
||||||
|
`npx ncc build ${join("dist", "bin", "main.js")} --external prettier -o ${join("dist", "ncc_out")}`
|
||||||
|
);
|
||||||
|
|
||||||
transformCodebase({
|
transformCodebase({
|
||||||
srcDirPath: join("dist", "ncc_out"),
|
srcDirPath: join("dist", "ncc_out"),
|
||||||
@ -88,6 +91,7 @@ import chalk from "chalk";
|
|||||||
|
|
||||||
run(`npx tsc -p ${join("src", "tsconfig.json")}`);
|
run(`npx tsc -p ${join("src", "tsconfig.json")}`);
|
||||||
run(`npx tsc-alias -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"))) {
|
if (fs.existsSync(join("dist", "vite-plugin", "index.original.js"))) {
|
||||||
fs.renameSync(
|
fs.renameSync(
|
||||||
@ -111,7 +115,7 @@ import chalk from "chalk";
|
|||||||
}
|
}
|
||||||
|
|
||||||
run(
|
run(
|
||||||
`npx ncc build ${join("dist", "vite-plugin", "index.js")} -o ${join(
|
`npx ncc build ${join("dist", "vite-plugin", "index.js")} --external prettier -o ${join(
|
||||||
"dist",
|
"dist",
|
||||||
"ncc_out"
|
"ncc_out"
|
||||||
)}`
|
)}`
|
||||||
@ -164,12 +168,6 @@ import chalk from "chalk";
|
|||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function run(command: string) {
|
|
||||||
console.log(chalk.grey(`$ ${command}`));
|
|
||||||
|
|
||||||
child_process.execSync(command, { stdio: "inherit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchDeprecatedBufferApiUsage(filePath: string) {
|
function patchDeprecatedBufferApiUsage(filePath: string) {
|
||||||
const before = fs.readFileSync(filePath).toString("utf8");
|
const before = fs.readFileSync(filePath).toString("utf8");
|
||||||
|
|
||||||
|
95
scripts/build/vendorFrontendDependencies.ts
Normal file
95
scripts/build/vendorFrontendDependencies.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
}
|
@ -6,6 +6,7 @@ import chalk from "chalk";
|
|||||||
import { Deferred } from "evt/tools/Deferred";
|
import { Deferred } from "evt/tools/Deferred";
|
||||||
import { assert } from "tsafe/assert";
|
import { assert } from "tsafe/assert";
|
||||||
import { is } from "tsafe/is";
|
import { is } from "tsafe/is";
|
||||||
|
import { run } from "./shared/run";
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
{
|
{
|
||||||
@ -84,9 +85,3 @@ import { is } from "tsafe/is";
|
|||||||
|
|
||||||
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
|
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" });
|
|
||||||
}
|
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import "minimal-polyfills/Object.fromEntries";
|
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import {
|
import {
|
||||||
join as pathJoin,
|
join as pathJoin,
|
||||||
@ -13,7 +12,8 @@ import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTh
|
|||||||
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
|
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
|
||||||
import { deepAssign } from "../src/tools/deepAssign";
|
import { deepAssign } from "../src/tools/deepAssign";
|
||||||
import { THEME_TYPES } from "../src/bin/shared/constants";
|
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) {
|
if (require.main === module) {
|
||||||
generateI18nMessages();
|
generateI18nMessages();
|
||||||
@ -22,11 +22,22 @@ if (require.main === module) {
|
|||||||
async function generateI18nMessages() {
|
async function generateI18nMessages() {
|
||||||
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
|
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
|
||||||
|
|
||||||
|
const accountI18nDirPath = pathJoin(
|
||||||
|
thisCodebaseRootDirPath,
|
||||||
|
"src",
|
||||||
|
"account",
|
||||||
|
"i18n"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fs.existsSync(accountI18nDirPath)) {
|
||||||
|
fs.rmSync(accountI18nDirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
type Dictionary = { [idiomId: string]: string };
|
type Dictionary = { [idiomId: string]: string };
|
||||||
|
|
||||||
const record: { [themeType: string]: { [language: string]: Dictionary } } = {};
|
const record: { [themeType: string]: { [language: string]: Dictionary } } = {};
|
||||||
|
|
||||||
for (const themeType of THEME_TYPES) {
|
for (const themeType of THEME_TYPES.filter(themeType => themeType !== "admin")) {
|
||||||
const { extractedDirPath } = await downloadKeycloakDefaultTheme({
|
const { extractedDirPath } = await downloadKeycloakDefaultTheme({
|
||||||
keycloakVersionId: (() => {
|
keycloakVersionId: (() => {
|
||||||
switch (themeType) {
|
switch (themeType) {
|
||||||
@ -139,6 +150,26 @@ async function generateI18nMessages() {
|
|||||||
"messages_defaultSet"
|
"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 = [
|
const generatedFileHeader = [
|
||||||
`//This code was automatically generated by running ${pathRelative(
|
`//This code was automatically generated by running ${pathRelative(
|
||||||
thisCodebaseRootDirPath,
|
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<
|
const keycloakifyExtraMessages_login: Record<
|
||||||
|
@ -55,7 +55,6 @@ const commonThirdPartyDeps = [
|
|||||||
Buffer.from(modifiedPackageJsonContent, "utf8")
|
Buffer.from(modifiedPackageJsonContent, "utf8")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const yarnGlobalDirPath = pathJoin(rootDirPath, ".yarn_home");
|
const yarnGlobalDirPath = pathJoin(rootDirPath, ".yarn_home");
|
||||||
|
|
||||||
fs.rmSync(yarnGlobalDirPath, { recursive: true, force: true });
|
fs.rmSync(yarnGlobalDirPath, { recursive: true, force: true });
|
||||||
@ -64,6 +63,21 @@ fs.mkdirSync(yarnGlobalDirPath);
|
|||||||
const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
|
const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
|
||||||
const { targetModuleName, cwd } = params;
|
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 = [
|
const cmd = [
|
||||||
"yarn",
|
"yarn",
|
||||||
"link",
|
"link",
|
||||||
@ -77,7 +91,10 @@ const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
|
|||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
...(os.platform() === "win32"
|
...(os.platform() === "win32"
|
||||||
? { USERPROFILE: yarnGlobalDirPath }
|
? {
|
||||||
|
USERPROFILE: yarnGlobalDirPath,
|
||||||
|
LOCALAPPDATA: yarnGlobalDirPath
|
||||||
|
}
|
||||||
: { HOME: yarnGlobalDirPath })
|
: { HOME: yarnGlobalDirPath })
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -108,7 +125,54 @@ if (testAppPaths.length === 0) {
|
|||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
testAppPaths.forEach(testAppPath => execSync("yarn install", { cwd: testAppPath }));
|
testAppPaths.forEach(testAppPath => {
|
||||||
|
const packageJsonFilePath = pathJoin(testAppPath, "package.json");
|
||||||
|
|
||||||
|
const packageJsonContent = fs.readFileSync(packageJsonFilePath);
|
||||||
|
|
||||||
|
const parsedPackageJson = JSON.parse(packageJsonContent.toString("utf8")) as {
|
||||||
|
scripts?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasPostInstallOrPrepareScript = false;
|
||||||
|
|
||||||
|
if (parsedPackageJson.scripts !== undefined) {
|
||||||
|
for (const scriptName of ["postinstall", "prepare"]) {
|
||||||
|
if (parsedPackageJson.scripts[scriptName] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPostInstallOrPrepareScript = true;
|
||||||
|
|
||||||
|
delete parsedPackageJson.scripts[scriptName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPostInstallOrPrepareScript) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
packageJsonFilePath,
|
||||||
|
Buffer.from(JSON.stringify(parsedPackageJson, null, 2), "utf8")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const restorePackageJson = () => {
|
||||||
|
if (!hasPostInstallOrPrepareScript) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(packageJsonFilePath, packageJsonContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync("yarn install", { cwd: testAppPath });
|
||||||
|
} catch (error) {
|
||||||
|
restorePackageJson();
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
restorePackageJson();
|
||||||
|
});
|
||||||
|
|
||||||
console.log("=== Linking common dependencies ===");
|
console.log("=== Linking common dependencies ===");
|
||||||
|
|
||||||
@ -155,4 +219,20 @@ testAppPaths.forEach(testAppPath =>
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testAppPaths.forEach(testAppPath => {
|
||||||
|
const { scripts = {} } = JSON.parse(
|
||||||
|
fs.readFileSync(pathJoin(testAppPath, "package.json")).toString("utf8")
|
||||||
|
) as {
|
||||||
|
scripts?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const scriptName of ["postinstall", "prepare"]) {
|
||||||
|
if (scripts[scriptName] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
execSync(`yarn run ${scriptName}`, { cwd: testAppPath });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
@ -1,55 +1,88 @@
|
|||||||
import * as child_process from "child_process";
|
|
||||||
import * as fs from "fs";
|
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 { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
|
||||||
import { crawl } from "../src/bin/tools/crawl";
|
|
||||||
|
|
||||||
{
|
(async () => {
|
||||||
const dirPath = "node_modules";
|
const parentDirPath = pathJoin(getThisCodebaseRootDirPath(), "..");
|
||||||
|
|
||||||
try {
|
const { starterName } = await (async () => {
|
||||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
const starterNames = fs
|
||||||
} catch {
|
.readdirSync(parentDirPath)
|
||||||
// NOTE: This is a workaround for windows
|
.filter(
|
||||||
// we can't remove locked executables.
|
basename =>
|
||||||
|
basename.includes("starter") &&
|
||||||
|
basename.includes("keycloakify") &&
|
||||||
|
fs.statSync(pathJoin(parentDirPath, basename)).isDirectory()
|
||||||
|
);
|
||||||
|
|
||||||
crawl({
|
if (starterNames.length === 0) {
|
||||||
dirPath,
|
console.log(
|
||||||
returnedPathsType: "absolute"
|
chalk.red(
|
||||||
}).forEach(filePath => {
|
`No starter found. Keycloakify Angular starter found in ${parentDirPath}`
|
||||||
try {
|
)
|
||||||
fs.rmSync(filePath, { force: true });
|
);
|
||||||
} catch (error) {
|
process.exit(-1);
|
||||||
if (filePath.endsWith(".exe")) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
|
const starterName = await (async () => {
|
||||||
|
if (starterNames.length === 1) {
|
||||||
|
return starterNames[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(chalk.cyan(`\nSelect a starter to link in:`));
|
||||||
|
|
||||||
|
const { value } = await cliSelect<string>({
|
||||||
|
values: starterNames.map(starterName => `..${pathSep}${starterName}`)
|
||||||
|
}).catch(() => {
|
||||||
|
process.exit(-1);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.rmSync("dist", { recursive: true, force: true });
|
return value.split(pathSep)[1];
|
||||||
fs.rmSync(".yarn_home", { recursive: true, force: true });
|
})();
|
||||||
|
|
||||||
run("yarn install");
|
return { starterName };
|
||||||
run("yarn build");
|
})();
|
||||||
|
|
||||||
const starterName = "keycloakify-starter";
|
const startTime = Date.now();
|
||||||
|
|
||||||
fs.rmSync(join("..", starterName, "node_modules"), {
|
console.log(chalk.cyan(`\n\nLinking in ..${pathSep}${starterName}...`));
|
||||||
|
|
||||||
|
removeNodeModules({
|
||||||
|
nodeModulesDirPath: pathJoin(getThisCodebaseRootDirPath(), "node_modules")
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.rmSync(pathJoin(getThisCodebaseRootDirPath(), "dist"), {
|
||||||
|
recursive: true,
|
||||||
|
force: true
|
||||||
|
});
|
||||||
|
fs.rmSync(pathJoin(getThisCodebaseRootDirPath(), ".yarn_home"), {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
force: true
|
force: true
|
||||||
});
|
});
|
||||||
|
|
||||||
run("yarn install", { cwd: join("..", starterName) });
|
run("yarn install");
|
||||||
|
run("yarn build");
|
||||||
|
|
||||||
run(`npx tsx ${join("scripts", "link-in-app.ts")} ${starterName}`);
|
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();
|
startRebuildOnSrcChange();
|
||||||
|
|
||||||
function run(command: string, options?: { cwd: string }) {
|
console.log(chalk.green(`\n\nLinked in ${starterName} in ${durationSeconds}s`));
|
||||||
console.log(`$ ${command}`);
|
})();
|
||||||
|
|
||||||
child_process.execSync(command, { stdio: "inherit", ...options });
|
|
||||||
}
|
|
||||||
|
9
scripts/shared/cacheDirPath.ts
Normal file
9
scripts/shared/cacheDirPath.ts
Normal 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"
|
||||||
|
);
|
@ -2,8 +2,9 @@ import { relative as pathRelative } from "path";
|
|||||||
import { downloadAndExtractArchive } from "../../src/bin/tools/downloadAndExtractArchive";
|
import { downloadAndExtractArchive } from "../../src/bin/tools/downloadAndExtractArchive";
|
||||||
import { getProxyFetchOptions } from "../../src/bin/tools/fetchProxyOptions";
|
import { getProxyFetchOptions } from "../../src/bin/tools/fetchProxyOptions";
|
||||||
import { join as pathJoin } from "path";
|
import { join as pathJoin } from "path";
|
||||||
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
|
|
||||||
import { assert, type Equals } from "tsafe/assert";
|
import { assert, type Equals } from "tsafe/assert";
|
||||||
|
import { cacheDirPath } from "./cacheDirPath";
|
||||||
|
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
|
||||||
|
|
||||||
const KEYCLOAK_VERSION = {
|
const KEYCLOAK_VERSION = {
|
||||||
FOR_LOGIN_THEME: "25.0.4",
|
FOR_LOGIN_THEME: "25.0.4",
|
||||||
@ -22,12 +23,7 @@ export async function downloadKeycloakDefaultTheme(params: {
|
|||||||
|
|
||||||
const { extractedDirPath } = await downloadAndExtractArchive({
|
const { extractedDirPath } = await downloadAndExtractArchive({
|
||||||
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
|
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
|
||||||
cacheDirPath: pathJoin(
|
cacheDirPath,
|
||||||
getThisCodebaseRootDirPath(),
|
|
||||||
"node_modules",
|
|
||||||
".cache",
|
|
||||||
"scripts"
|
|
||||||
),
|
|
||||||
fetchOptions: getProxyFetchOptions({
|
fetchOptions: getProxyFetchOptions({
|
||||||
npmConfigGetCwd: getThisCodebaseRootDirPath()
|
npmConfigGetCwd: getThisCodebaseRootDirPath()
|
||||||
}),
|
}),
|
||||||
|
8
scripts/shared/run.ts
Normal file
8
scripts/shared/run.ts
Normal 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 });
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import * as child_process from "child_process";
|
import * as child_process from "child_process";
|
||||||
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
|
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
|
||||||
|
import { run } from "./shared/run";
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
run("yarn build");
|
run("yarn build");
|
||||||
@ -18,9 +19,3 @@ import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
|
|||||||
|
|
||||||
startRebuildOnSrcChange();
|
startRebuildOnSrcChange();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
|
|
||||||
console.log(`$ ${command}`);
|
|
||||||
|
|
||||||
child_process.execSync(command, { stdio: "inherit", ...options });
|
|
||||||
}
|
|
||||||
|
27
scripts/tools/removeNodeModules.ts
Normal file
27
scripts/tools/removeNodeModules.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,8 @@ import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "keycloakify/bin/shared/constants
|
|||||||
import { id } from "tsafe/id";
|
import { id } from "tsafe/id";
|
||||||
import type { KcContext } from "./KcContext";
|
import type { KcContext } from "./KcContext";
|
||||||
import { BASE_URL } from "keycloakify/lib/BASE_URL";
|
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.KEYCLOAKIFY_DEV_RESOURCES}/account`;
|
const resourcesPath = `${BASE_URL}${WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES}/account`;
|
||||||
|
|
||||||
@ -38,7 +40,8 @@ export const kcContextCommonMock: KcContext.Common = {
|
|||||||
exists: () => false
|
exists: () => false
|
||||||
},
|
},
|
||||||
locale: {
|
locale: {
|
||||||
supported: [
|
supported: (
|
||||||
|
[
|
||||||
/* spell-checker: disable */
|
/* spell-checker: disable */
|
||||||
["de", "Deutsch"],
|
["de", "Deutsch"],
|
||||||
["no", "Norsk"],
|
["no", "Norsk"],
|
||||||
@ -57,16 +60,33 @@ export const kcContextCommonMock: KcContext.Common = {
|
|||||||
["pl", "Polski"],
|
["pl", "Polski"],
|
||||||
["ca", "Català"],
|
["ca", "Català"],
|
||||||
["nl", "Nederlands"],
|
["nl", "Nederlands"],
|
||||||
["tr", "Türkçe"]
|
["tr", "Türkçe"],
|
||||||
|
["ar", "العربية"],
|
||||||
|
["da", "Dansk"],
|
||||||
|
["fi", "Suomi"],
|
||||||
|
["hu", "Magyar"],
|
||||||
|
["lv", "Latviešu"]
|
||||||
/* spell-checker: enable */
|
/* spell-checker: enable */
|
||||||
].map(
|
] as const
|
||||||
([languageTag, label]) =>
|
).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,
|
languageTag,
|
||||||
label,
|
label,
|
||||||
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
|
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
|
||||||
}) as const
|
} as const;
|
||||||
),
|
}),
|
||||||
|
|
||||||
currentLanguageTag: "en"
|
currentLanguageTag: "en"
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { assert } from "keycloakify/tools/assert";
|
|
||||||
import { clsx } from "keycloakify/tools/clsx";
|
import { clsx } from "keycloakify/tools/clsx";
|
||||||
|
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||||
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
||||||
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
|
|
||||||
import { useSetClassName } from "keycloakify/tools/useSetClassName";
|
import { useSetClassName } from "keycloakify/tools/useSetClassName";
|
||||||
|
import { useInitialize } from "keycloakify/account/Template.useInitialize";
|
||||||
import type { TemplateProps } from "keycloakify/account/TemplateProps";
|
import type { TemplateProps } from "keycloakify/account/TemplateProps";
|
||||||
import type { I18n } from "./i18n";
|
import type { I18n } from "./i18n";
|
||||||
import type { KcContext } from "./KcContext";
|
import type { KcContext } from "./KcContext";
|
||||||
@ -13,9 +13,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|||||||
|
|
||||||
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
|
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(() => {
|
useEffect(() => {
|
||||||
document.title = msgStr("accountManagementTitle");
|
document.title = msgStr("accountManagementTitle");
|
||||||
@ -31,30 +31,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|||||||
className: clsx("admin-console", "user", kcClsx("kcBodyClass"))
|
className: clsx("admin-console", "user", kcClsx("kcBodyClass"))
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss });
|
||||||
const { currentLanguageTag } = locale ?? {};
|
|
||||||
|
|
||||||
if (currentLanguageTag === undefined) {
|
if (!isReadyToRender) {
|
||||||
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) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,16 +49,16 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|||||||
<div className="navbar-collapse navbar-collapse-1">
|
<div className="navbar-collapse navbar-collapse-1">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<ul className="nav navbar-nav navbar-utility">
|
<ul className="nav navbar-nav navbar-utility">
|
||||||
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
|
{enabledLanguages.length > 1 && (
|
||||||
<li>
|
<li>
|
||||||
<div className="kc-dropdown" id="kc-locale-dropdown">
|
<div className="kc-dropdown" id="kc-locale-dropdown">
|
||||||
<a href="#" id="kc-current-locale-link">
|
<a href="#" id="kc-current-locale-link">
|
||||||
{labelBySupportedLanguageTag[currentLanguageTag]}
|
{currentLanguage.label}
|
||||||
</a>
|
</a>
|
||||||
<ul>
|
<ul>
|
||||||
{locale.supported.map(({ languageTag }) => (
|
{enabledLanguages.map(({ languageTag, label, href }) => (
|
||||||
<li key={languageTag} className="kc-dropdown-item">
|
<li key={languageTag} className="kc-dropdown-item">
|
||||||
<a href={getChangeLocaleUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
|
<a href={href}>{label}</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -148,7 +127,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|||||||
<span
|
<span
|
||||||
className="kc-feedback-text"
|
className="kc-feedback-text"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: message.summary
|
__html: kcSanitize(message.summary)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
35
src/account/Template.useInitialize.ts
Normal file
35
src/account/Template.useInitialize.ts
Normal 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 };
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import type { ClassKey } from "keycloakify/account/lib/kcClsx";
|
||||||
|
|
||||||
export type TemplateProps<KcContext, I18n> = {
|
export type TemplateProps<KcContext, I18n> = {
|
||||||
kcContext: KcContext;
|
kcContext: KcContext;
|
||||||
@ -10,17 +11,4 @@ export type TemplateProps<KcContext, I18n> = {
|
|||||||
active: string;
|
active: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClassKey =
|
export type { ClassKey };
|
||||||
| "kcHtmlClass"
|
|
||||||
| "kcBodyClass"
|
|
||||||
| "kcButtonClass"
|
|
||||||
| "kcButtonPrimaryClass"
|
|
||||||
| "kcButtonLargeClass"
|
|
||||||
| "kcButtonDefaultClass"
|
|
||||||
| "kcContentWrapperClass"
|
|
||||||
| "kcFormClass"
|
|
||||||
| "kcFormGroupClass"
|
|
||||||
| "kcInputWrapperClass"
|
|
||||||
| "kcLabelClass"
|
|
||||||
| "kcInputClass"
|
|
||||||
| "kcInputErrorMessageClass";
|
|
||||||
|
@ -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;
|
|
||||||
};
|
|
@ -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 <strong>XXX</strong>"
|
|
||||||
* 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, "<").replace(/>/g, ">")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
@ -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";
|
|
@ -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>() };
|
|
||||||
}
|
|
@ -1,3 +1,3 @@
|
|||||||
export type { ExtendKcContext } from "keycloakify/account/KcContext";
|
export type { ExtendKcContext } from "keycloakify/account/KcContext";
|
||||||
export type { ClassKey } from "keycloakify/account/TemplateProps";
|
export type { ClassKey } from "keycloakify/account/TemplateProps";
|
||||||
export { createUseI18n } from "keycloakify/account/i18n";
|
export { i18nBuilder, type MessageKey_defaultSet } from "keycloakify/account/i18n";
|
||||||
|
@ -1,5 +1,19 @@
|
|||||||
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
|
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>({
|
export const { getKcClsx } = createGetKcClsx<ClassKey>({
|
||||||
defaultClasses: {
|
defaultClasses: {
|
||||||
@ -20,6 +34,4 @@ export const { getKcClsx } = createGetKcClsx<ClassKey>({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type { ClassKey };
|
|
||||||
|
|
||||||
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];
|
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { clsx } from "keycloakify/tools/clsx";
|
import { clsx } from "keycloakify/tools/clsx";
|
||||||
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
||||||
|
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||||
import type { KcContext } from "../KcContext";
|
import type { KcContext } from "../KcContext";
|
||||||
import type { I18n } from "../i18n";
|
import type { I18n } from "../i18n";
|
||||||
@ -159,7 +160,7 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
|
|||||||
className={kcClsx("kcInputErrorMessageClass")}
|
className={kcClsx("kcInputErrorMessageClass")}
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
dangerouslySetInnerHTML={{
|
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")}
|
className={kcClsx("kcInputErrorMessageClass")}
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: messagesPerField.get("userLabel")
|
__html: kcSanitize(messagesPerField.get("userLabel"))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -5,25 +5,30 @@ import {
|
|||||||
ACCOUNT_THEME_PAGE_IDS,
|
ACCOUNT_THEME_PAGE_IDS,
|
||||||
type LoginThemePageId,
|
type LoginThemePageId,
|
||||||
type AccountThemePageId,
|
type AccountThemePageId,
|
||||||
THEME_TYPES,
|
THEME_TYPES
|
||||||
type ThemeType
|
|
||||||
} from "./shared/constants";
|
} from "./shared/constants";
|
||||||
import { capitalize } from "tsafe/capitalize";
|
import { capitalize } from "tsafe/capitalize";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
|
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
|
||||||
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
|
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
|
||||||
import { assert, Equals } from "tsafe/assert";
|
import { assert, Equals } from "tsafe/assert";
|
||||||
import type { CliCommandOptions } from "./main";
|
import type { BuildContext } from "./shared/buildContext";
|
||||||
import { getBuildContext } from "./shared/buildContext";
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import { runPrettier, getIsPrettierAvailable } from "./tools/runPrettier";
|
||||||
|
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
|
||||||
|
|
||||||
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
|
export async function command(params: { buildContext: BuildContext }) {
|
||||||
const { cliCommandOptions } = params;
|
const { buildContext } = params;
|
||||||
|
|
||||||
const buildContext = getBuildContext({
|
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||||
cliCommandOptions
|
commandName: "add-story",
|
||||||
|
buildContext
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (hasBeenHandled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(chalk.cyan("Theme type:"));
|
console.log(chalk.cyan("Theme type:"));
|
||||||
|
|
||||||
const themeType = await (async () => {
|
const themeType = await (async () => {
|
||||||
@ -33,6 +38,8 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
return buildContext.implementedThemeTypes.account.isImplemented;
|
return buildContext.implementedThemeTypes.account.isImplemented;
|
||||||
case "login":
|
case "login":
|
||||||
return buildContext.implementedThemeTypes.login.isImplemented;
|
return buildContext.implementedThemeTypes.login.isImplemented;
|
||||||
|
case "admin":
|
||||||
|
return buildContext.implementedThemeTypes.admin.isImplemented;
|
||||||
}
|
}
|
||||||
assert<Equals<typeof themeType, never>>(false);
|
assert<Equals<typeof themeType, never>>(false);
|
||||||
});
|
});
|
||||||
@ -43,7 +50,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
return values[0];
|
return values[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { value } = await cliSelect<ThemeType>({
|
const { value } = await cliSelect({
|
||||||
values
|
values
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
@ -62,6 +69,16 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
);
|
);
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (themeType === "admin") {
|
||||||
|
console.log(
|
||||||
|
`${chalk.red("✗")} Sorry, there is no Storybook support for the Account UI.`
|
||||||
|
);
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`→ ${themeType}`);
|
console.log(`→ ${themeType}`);
|
||||||
@ -102,7 +119,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentCode = fs
|
let sourceCode = fs
|
||||||
.readFileSync(
|
.readFileSync(
|
||||||
pathJoin(
|
pathJoin(
|
||||||
getThisCodebaseRootDirPath(),
|
getThisCodebaseRootDirPath(),
|
||||||
@ -116,6 +133,17 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
.replace('import React from "react";\n', "")
|
.replace('import React from "react";\n', "")
|
||||||
.replace(/from "[./]+dist\//, 'from "keycloakify/');
|
.replace(/from "[./]+dist\//, 'from "keycloakify/');
|
||||||
|
|
||||||
|
run_prettier: {
|
||||||
|
if (!(await getIsPrettierAvailable())) {
|
||||||
|
break run_prettier;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceCode = await runPrettier({
|
||||||
|
filePath: targetFilePath,
|
||||||
|
sourceCode: sourceCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const targetDirPath = pathDirname(targetFilePath);
|
const targetDirPath = pathDirname(targetFilePath);
|
||||||
|
|
||||||
@ -124,7 +152,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
|
fs.writeFileSync(targetFilePath, Buffer.from(sourceCode, "utf8"));
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
[
|
[
|
||||||
|
@ -1,13 +1,96 @@
|
|||||||
import { copyKeycloakResourcesToPublic } from "./shared/copyKeycloakResourcesToPublic";
|
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
|
||||||
import { getBuildContext } from "./shared/buildContext";
|
import { join as pathJoin, dirname as pathDirname } from "path";
|
||||||
import type { CliCommandOptions } from "./main";
|
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 }) {
|
export async function command(params: { buildContext: BuildContext }) {
|
||||||
const { cliCommandOptions } = params;
|
const { buildContext } = params;
|
||||||
|
|
||||||
const buildContext = getBuildContext({ cliCommandOptions });
|
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||||
|
commandName: "copy-keycloak-resources-to-public",
|
||||||
copyKeycloakResourcesToPublic({
|
|
||||||
buildContext
|
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")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
68
src/bin/eject-file.ts
Normal file
68
src/bin/eject-file.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import type { BuildContext } from "./shared/buildContext";
|
||||||
|
import { getUiModuleFileSourceCodeReadyToBeCopied } from "./postinstall/getUiModuleFileSourceCodeReadyToBeCopied";
|
||||||
|
import { getAbsoluteAndInOsFormatPath } from "./tools/getAbsoluteAndInOsFormatPath";
|
||||||
|
import { relative as pathRelative, dirname as pathDirname, join as pathJoin } from "path";
|
||||||
|
import { getUiModuleMetas } from "./postinstall/uiModuleMeta";
|
||||||
|
import { getInstalledModuleDirPath } from "./tools/getInstalledModuleDirPath";
|
||||||
|
import * as fsPr from "fs/promises";
|
||||||
|
import {
|
||||||
|
readManagedGitignoreFile,
|
||||||
|
writeManagedGitignoreFile
|
||||||
|
} from "./postinstall/managedGitignoreFile";
|
||||||
|
|
||||||
|
export async function command(params: {
|
||||||
|
buildContext: BuildContext;
|
||||||
|
cliCommandOptions: {
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const { buildContext, cliCommandOptions } = params;
|
||||||
|
|
||||||
|
const fileRelativePath = pathRelative(
|
||||||
|
buildContext.themeSrcDirPath,
|
||||||
|
getAbsoluteAndInOsFormatPath({
|
||||||
|
cwd: buildContext.themeSrcDirPath,
|
||||||
|
pathIsh: cliCommandOptions.file
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const uiModuleMetas = await getUiModuleMetas({ buildContext });
|
||||||
|
|
||||||
|
const uiModuleMeta = uiModuleMetas.find(({ files }) =>
|
||||||
|
files.map(({ fileRelativePath }) => fileRelativePath).includes(fileRelativePath)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uiModuleMeta) {
|
||||||
|
throw new Error(`No UI module found for the file ${fileRelativePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiModuleDirPath = await getInstalledModuleDirPath({
|
||||||
|
moduleName: uiModuleMeta.moduleName,
|
||||||
|
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath),
|
||||||
|
projectDirPath: buildContext.projectDirPath
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceCode = await getUiModuleFileSourceCodeReadyToBeCopied({
|
||||||
|
buildContext,
|
||||||
|
fileRelativePath,
|
||||||
|
isForEjection: true,
|
||||||
|
uiModuleName: uiModuleMeta.moduleName,
|
||||||
|
uiModuleDirPath,
|
||||||
|
uiModuleVersion: uiModuleMeta.version
|
||||||
|
});
|
||||||
|
|
||||||
|
await fsPr.writeFile(
|
||||||
|
pathJoin(buildContext.themeSrcDirPath, fileRelativePath),
|
||||||
|
sourceCode
|
||||||
|
);
|
||||||
|
|
||||||
|
const { ejectedFilesRelativePaths } = await readManagedGitignoreFile({
|
||||||
|
buildContext
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeManagedGitignoreFile({
|
||||||
|
buildContext,
|
||||||
|
uiModuleMetas,
|
||||||
|
ejectedFilesRelativePaths: [...ejectedFilesRelativePaths, fileRelativePath]
|
||||||
|
});
|
||||||
|
}
|
@ -7,8 +7,7 @@ import {
|
|||||||
ACCOUNT_THEME_PAGE_IDS,
|
ACCOUNT_THEME_PAGE_IDS,
|
||||||
type LoginThemePageId,
|
type LoginThemePageId,
|
||||||
type AccountThemePageId,
|
type AccountThemePageId,
|
||||||
THEME_TYPES,
|
THEME_TYPES
|
||||||
type ThemeType
|
|
||||||
} from "./shared/constants";
|
} from "./shared/constants";
|
||||||
import { capitalize } from "tsafe/capitalize";
|
import { capitalize } from "tsafe/capitalize";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
@ -20,17 +19,23 @@ import {
|
|||||||
} from "path";
|
} from "path";
|
||||||
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
|
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
|
||||||
import { assert, Equals } from "tsafe/assert";
|
import { assert, Equals } from "tsafe/assert";
|
||||||
import type { CliCommandOptions } from "./main";
|
import type { BuildContext } from "./shared/buildContext";
|
||||||
import { getBuildContext } from "./shared/buildContext";
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
|
||||||
|
import { runPrettier, getIsPrettierAvailable } from "./tools/runPrettier";
|
||||||
|
|
||||||
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
|
export async function command(params: { buildContext: BuildContext }) {
|
||||||
const { cliCommandOptions } = params;
|
const { buildContext } = params;
|
||||||
|
|
||||||
const buildContext = getBuildContext({
|
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||||
cliCommandOptions
|
commandName: "eject-page",
|
||||||
|
buildContext
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (hasBeenHandled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(chalk.cyan("Theme type:"));
|
console.log(chalk.cyan("Theme type:"));
|
||||||
|
|
||||||
const themeType = await (async () => {
|
const themeType = await (async () => {
|
||||||
@ -40,6 +45,8 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
return buildContext.implementedThemeTypes.account.isImplemented;
|
return buildContext.implementedThemeTypes.account.isImplemented;
|
||||||
case "login":
|
case "login":
|
||||||
return buildContext.implementedThemeTypes.login.isImplemented;
|
return buildContext.implementedThemeTypes.login.isImplemented;
|
||||||
|
case "admin":
|
||||||
|
return buildContext.implementedThemeTypes.admin.isImplemented;
|
||||||
}
|
}
|
||||||
assert<Equals<typeof themeType, never>>(false);
|
assert<Equals<typeof themeType, never>>(false);
|
||||||
});
|
});
|
||||||
@ -50,7 +57,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
return values[0];
|
return values[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { value } = await cliSelect<ThemeType>({
|
const { value } = await cliSelect({
|
||||||
values
|
values
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
@ -59,6 +66,14 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
return value;
|
return value;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
if (themeType === "admin") {
|
||||||
|
console.log(
|
||||||
|
"Use `npx keycloakify eject-file` command instead, see documentation"
|
||||||
|
);
|
||||||
|
|
||||||
|
process.exit(-1);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
themeType === "account" &&
|
themeType === "account" &&
|
||||||
(assert(buildContext.implementedThemeTypes.account.isImplemented),
|
(assert(buildContext.implementedThemeTypes.account.isImplemented),
|
||||||
@ -68,13 +83,13 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
pathDirname(buildContext.packageJsonFilePath),
|
pathDirname(buildContext.packageJsonFilePath),
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"@keycloakify",
|
"@keycloakify",
|
||||||
"keycloak-account-ui",
|
`keycloak-account-ui`,
|
||||||
"src"
|
"src"
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
[
|
[
|
||||||
`There isn't an interactive CLI to eject components of the Single-Page Account theme.`,
|
`There isn't an interactive CLI to eject components of the Account SPA UI.`,
|
||||||
`You can however copy paste into your codebase the any file or directory from the following source directory:`,
|
`You can however copy paste into your codebase the any file or directory from the following source directory:`,
|
||||||
``,
|
``,
|
||||||
`${chalk.bold(pathJoin(pathRelative(process.cwd(), srcDirPath)))}`,
|
`${chalk.bold(pathJoin(pathRelative(process.cwd(), srcDirPath)))}`,
|
||||||
@ -83,41 +98,44 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
);
|
);
|
||||||
|
|
||||||
eject_entrypoint: {
|
eject_entrypoint: {
|
||||||
const kcAccountUiTsxFileRelativePath = "KcAccountUi.tsx";
|
const kcUiTsxFileRelativePath = `KcAccountUi.tsx` as const;
|
||||||
|
|
||||||
const accountThemeSrcDirPath = pathJoin(
|
const themeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
|
||||||
buildContext.themeSrcDirPath,
|
|
||||||
"account"
|
|
||||||
);
|
|
||||||
|
|
||||||
const targetFilePath = pathJoin(
|
const targetFilePath = pathJoin(themeSrcDirPath, kcUiTsxFileRelativePath);
|
||||||
accountThemeSrcDirPath,
|
|
||||||
kcAccountUiTsxFileRelativePath
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fs.existsSync(targetFilePath)) {
|
if (fs.existsSync(targetFilePath)) {
|
||||||
break eject_entrypoint;
|
break eject_entrypoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.cpSync(
|
fs.cpSync(pathJoin(srcDirPath, kcUiTsxFileRelativePath), targetFilePath);
|
||||||
pathJoin(srcDirPath, kcAccountUiTsxFileRelativePath),
|
|
||||||
targetFilePath
|
|
||||||
);
|
|
||||||
|
|
||||||
{
|
{
|
||||||
const kcPageTsxFilePath = pathJoin(accountThemeSrcDirPath, "KcPage.tsx");
|
const kcPageTsxFilePath = pathJoin(themeSrcDirPath, "KcPage.tsx");
|
||||||
|
|
||||||
const kcPageTsxCode = fs.readFileSync(kcPageTsxFilePath).toString("utf8");
|
const kcPageTsxCode = fs.readFileSync(kcPageTsxFilePath).toString("utf8");
|
||||||
|
|
||||||
const componentName = pathBasename(
|
const componentName = pathBasename(kcUiTsxFileRelativePath).replace(
|
||||||
kcAccountUiTsxFileRelativePath
|
/.tsx$/,
|
||||||
).replace(/.tsx$/, "");
|
""
|
||||||
|
);
|
||||||
|
|
||||||
const modifiedKcPageTsxCode = kcPageTsxCode.replace(
|
let modifiedKcPageTsxCode = kcPageTsxCode.replace(
|
||||||
`@keycloakify/keycloak-account-ui/${componentName}`,
|
`@keycloakify/keycloak-account-ui/${componentName}`,
|
||||||
`./${componentName}`
|
`./${componentName}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
run_prettier: {
|
||||||
|
if (!(await getIsPrettierAvailable())) {
|
||||||
|
break run_prettier;
|
||||||
|
}
|
||||||
|
|
||||||
|
modifiedKcPageTsxCode = await runPrettier({
|
||||||
|
filePath: kcPageTsxFilePath,
|
||||||
|
sourceCode: modifiedKcPageTsxCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
kcPageTsxFilePath,
|
kcPageTsxFilePath,
|
||||||
Buffer.from(modifiedKcPageTsxCode, "utf8")
|
Buffer.from(modifiedKcPageTsxCode, "utf8")
|
||||||
@ -133,13 +151,14 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
[
|
[
|
||||||
`To help you get started ${chalk.bold(pathRelative(process.cwd(), targetFilePath))} has been copied into your project.`,
|
`To help you get started ${chalk.bold(pathRelative(process.cwd(), targetFilePath))} has been copied into your project.`,
|
||||||
`The next step is usually to eject ${chalk.bold(routesTsxFilePath)}`,
|
`The next step is usually to eject ${chalk.bold(routesTsxFilePath)}`,
|
||||||
`with \`cp ${routesTsxFilePath} ${pathRelative(process.cwd(), accountThemeSrcDirPath)}\``,
|
`with \`cp ${routesTsxFilePath} ${pathRelative(process.cwd(), themeSrcDirPath)}\``,
|
||||||
`then update the import of routes in ${kcAccountUiTsxFileRelativePath}.`
|
`then update the import of routes in ${kcUiTsxFileRelativePath}.`
|
||||||
].join("\n")
|
].join("\n")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`→ ${themeType}`);
|
console.log(`→ ${themeType}`);
|
||||||
@ -216,7 +235,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentCode = fs
|
let componentCode = fs
|
||||||
.readFileSync(
|
.readFileSync(
|
||||||
pathJoin(
|
pathJoin(
|
||||||
getThisCodebaseRootDirPath(),
|
getThisCodebaseRootDirPath(),
|
||||||
@ -228,6 +247,17 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
)
|
)
|
||||||
.toString("utf8");
|
.toString("utf8");
|
||||||
|
|
||||||
|
run_prettier: {
|
||||||
|
if (!(await getIsPrettierAvailable())) {
|
||||||
|
break run_prettier;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentCode = await runPrettier({
|
||||||
|
filePath: targetFilePath,
|
||||||
|
sourceCode: componentCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const targetDirPath = pathDirname(targetFilePath);
|
const targetDirPath = pathDirname(targetFilePath);
|
||||||
|
|
||||||
@ -244,12 +274,12 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
)} copy pasted from the Keycloakify source code into your project`
|
)} copy pasted from the Keycloakify source code into your project`
|
||||||
);
|
);
|
||||||
|
|
||||||
edit_KcApp: {
|
edit_KcPage: {
|
||||||
if (
|
if (
|
||||||
pageIdOrComponent !== templateValue &&
|
pageIdOrComponent !== templateValue &&
|
||||||
pageIdOrComponent !== userProfileFormFieldsValue
|
pageIdOrComponent !== userProfileFormFieldsValue
|
||||||
) {
|
) {
|
||||||
break edit_KcApp;
|
break edit_KcPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
const kcAppTsxPath = pathJoin(
|
const kcAppTsxPath = pathJoin(
|
||||||
|
@ -1,17 +1,24 @@
|
|||||||
import { getBuildContext } from "../shared/buildContext";
|
import type { BuildContext } from "../shared/buildContext";
|
||||||
import type { CliCommandOptions } from "../main";
|
|
||||||
import cliSelect from "cli-select";
|
import cliSelect from "cli-select";
|
||||||
import child_process from "child_process";
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { join as pathJoin, relative as pathRelative } from "path";
|
import { join as pathJoin, relative as pathRelative } from "path";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeImplementationInConfig";
|
import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeImplementationInConfig";
|
||||||
import { generateKcGenTs } from "../shared/generateKcGenTs";
|
import { command as updateKcGenCommand } from "../update-kc-gen";
|
||||||
|
import { maybeDelegateCommandToCustomHandler } from "../shared/customHandler_delegate";
|
||||||
|
import { exitIfUncommittedChanges } from "../shared/exitIfUncommittedChanges";
|
||||||
|
|
||||||
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
|
export async function command(params: { buildContext: BuildContext }) {
|
||||||
const { cliCommandOptions } = params;
|
const { buildContext } = params;
|
||||||
|
|
||||||
const buildContext = getBuildContext({ cliCommandOptions });
|
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||||
|
commandName: "initialize-account-theme",
|
||||||
|
buildContext
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasBeenHandled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
|
const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
|
||||||
|
|
||||||
@ -31,37 +38,9 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
exit_if_uncommitted_changes: {
|
exitIfUncommittedChanges({
|
||||||
let hasUncommittedChanges: boolean | undefined = undefined;
|
projectDirPath: buildContext.projectDirPath
|
||||||
|
});
|
||||||
try {
|
|
||||||
hasUncommittedChanges =
|
|
||||||
child_process
|
|
||||||
.execSync(`git status --porcelain`, {
|
|
||||||
cwd: buildContext.projectDirPath
|
|
||||||
})
|
|
||||||
.toString()
|
|
||||||
.trim() !== "";
|
|
||||||
} catch {
|
|
||||||
// Probably not a git repository
|
|
||||||
break exit_if_uncommitted_changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasUncommittedChanges) {
|
|
||||||
break exit_if_uncommitted_changes;
|
|
||||||
}
|
|
||||||
console.warn(
|
|
||||||
[
|
|
||||||
chalk.red(
|
|
||||||
"Please commit or stash your changes before running this command.\n"
|
|
||||||
),
|
|
||||||
"This command will modify your project's files so it's better to have a clean working directory",
|
|
||||||
"so that you can easily see what has been changed and revert if needed."
|
|
||||||
].join(" ")
|
|
||||||
);
|
|
||||||
|
|
||||||
process.exit(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { value: accountThemeType } = await cliSelect({
|
const { value: accountThemeType } = await cliSelect({
|
||||||
values: ["Single-Page" as const, "Multi-Page" as const]
|
values: ["Single-Page" as const, "Multi-Page" as const]
|
||||||
@ -97,7 +76,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
|
|
||||||
updateAccountThemeImplementationInConfig({ buildContext, accountThemeType });
|
updateAccountThemeImplementationInConfig({ buildContext, accountThemeType });
|
||||||
|
|
||||||
await generateKcGenTs({
|
await updateKcGenCommand({
|
||||||
buildContext: {
|
buildContext: {
|
||||||
...buildContext,
|
...buildContext,
|
||||||
implementedThemeTypes: {
|
implementedThemeTypes: {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
|
import { relative as pathRelative, dirname as pathDirname } from "path";
|
||||||
import type { BuildContext } from "../shared/buildContext";
|
import type { BuildContext } from "../shared/buildContext";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
@ -14,7 +14,6 @@ import { is } from "tsafe/is";
|
|||||||
import { id } from "tsafe/id";
|
import { id } from "tsafe/id";
|
||||||
import { npmInstall } from "../tools/npmInstall";
|
import { npmInstall } from "../tools/npmInstall";
|
||||||
import { copyBoilerplate } from "./copyBoilerplate";
|
import { copyBoilerplate } from "./copyBoilerplate";
|
||||||
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
|
|
||||||
|
|
||||||
type BuildContextLike = BuildContextLike_getLatestsSemVersionedTag & {
|
type BuildContextLike = BuildContextLike_getLatestsSemVersionedTag & {
|
||||||
fetchOptions: BuildContext["fetchOptions"];
|
fetchOptions: BuildContext["fetchOptions"];
|
||||||
@ -121,20 +120,7 @@ export async function initializeAccountTheme_singlePage(params: {
|
|||||||
JSON.stringify(parsedPackageJson, undefined, 4)
|
JSON.stringify(parsedPackageJson, undefined, 4)
|
||||||
);
|
);
|
||||||
|
|
||||||
run_npm_install: {
|
|
||||||
if (
|
|
||||||
JSON.parse(
|
|
||||||
fs
|
|
||||||
.readFileSync(pathJoin(getThisCodebaseRootDirPath(), "package.json"))
|
|
||||||
.toString("utf8")
|
|
||||||
)["version"] === "0.0.0"
|
|
||||||
) {
|
|
||||||
//NOTE: Linked version
|
|
||||||
break run_npm_install;
|
|
||||||
}
|
|
||||||
|
|
||||||
npmInstall({ packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath) });
|
npmInstall({ packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath) });
|
||||||
}
|
|
||||||
|
|
||||||
copyBoilerplate({
|
copyBoilerplate({
|
||||||
accountThemeType: "Single-Page",
|
accountThemeType: "Single-Page",
|
||||||
|
@ -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 };
|
||||||
|
@ -5,15 +5,18 @@ import * as fs from "fs";
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { id } from "tsafe/id";
|
import { id } from "tsafe/id";
|
||||||
|
import { is } from "tsafe/is";
|
||||||
|
|
||||||
export type BuildContextLike = {
|
export type BuildContextLike = {
|
||||||
bundler: BuildContext["bundler"];
|
bundler: BuildContext["bundler"];
|
||||||
|
projectDirPath: string;
|
||||||
|
packageJsonFilePath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||||
|
|
||||||
export function updateAccountThemeImplementationInConfig(params: {
|
export function updateAccountThemeImplementationInConfig(params: {
|
||||||
buildContext: BuildContext;
|
buildContext: BuildContextLike;
|
||||||
accountThemeType: "Single-Page" | "Multi-Page";
|
accountThemeType: "Single-Page" | "Multi-Page";
|
||||||
}) {
|
}) {
|
||||||
const { buildContext, accountThemeType } = params;
|
const { buildContext, accountThemeType } = params;
|
||||||
@ -81,6 +84,8 @@ export function updateAccountThemeImplementationInConfig(params: {
|
|||||||
|
|
||||||
zParsedPackageJson.parse(parsedPackageJson);
|
zParsedPackageJson.parse(parsedPackageJson);
|
||||||
|
|
||||||
|
assert(is<ParsedPackageJson>(parsedPackageJson));
|
||||||
|
|
||||||
return parsedPackageJson;
|
return parsedPackageJson;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
@ -1,15 +1,25 @@
|
|||||||
import { join as pathJoin, relative as pathRelative } from "path";
|
import { join as pathJoin, relative as pathRelative } from "path";
|
||||||
import { transformCodebase } from "./tools/transformCodebase";
|
import { transformCodebase } from "./tools/transformCodebase";
|
||||||
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
|
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
|
||||||
import { getBuildContext } from "./shared/buildContext";
|
import type { BuildContext } from "./shared/buildContext";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import type { CliCommandOptions } from "./main";
|
|
||||||
import { downloadAndExtractArchive } from "./tools/downloadAndExtractArchive";
|
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 }) {
|
export async function command(params: { buildContext: BuildContext }) {
|
||||||
const { cliCommandOptions } = params;
|
const { buildContext } = params;
|
||||||
|
|
||||||
const buildContext = getBuildContext({ cliCommandOptions });
|
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||||
|
commandName: "initialize-email-theme",
|
||||||
|
buildContext
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasBeenHandled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
|
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?");
|
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
|
// NOTE: This is arbitrary
|
||||||
startingFromMajor: 17,
|
startingFromMajor: 17,
|
||||||
excludeMajorVersions: [],
|
excludeMajorVersions: [],
|
||||||
@ -37,8 +47,32 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
buildContext
|
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({
|
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,
|
cacheDirPath: buildContext.cacheDirPath,
|
||||||
fetchOptions: buildContext.fetchOptions,
|
fetchOptions: buildContext.fetchOptions,
|
||||||
uniqueIdOfOnArchiveFile: "extractOnlyEmailTheme",
|
uniqueIdOfOnArchiveFile: "extractOnlyEmailTheme",
|
||||||
|
@ -16,6 +16,7 @@ import { isInside } from "../../tools/isInside";
|
|||||||
import child_process from "child_process";
|
import child_process from "child_process";
|
||||||
import { rmSync } from "../../tools/fs.rmSync";
|
import { rmSync } from "../../tools/fs.rmSync";
|
||||||
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
|
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
|
||||||
|
import { existsAsync } from "../../tools/fs.existsAsync";
|
||||||
|
|
||||||
export type BuildContextLike = BuildContextLike_generatePom & {
|
export type BuildContextLike = BuildContextLike_generatePom & {
|
||||||
keycloakifyBuildDirPath: string;
|
keycloakifyBuildDirPath: string;
|
||||||
@ -135,8 +136,10 @@ export async function buildJar(params: {
|
|||||||
break route_legacy_pages;
|
break route_legacy_pages;
|
||||||
}
|
}
|
||||||
|
|
||||||
(["register.ftl", "login-update-profile.ftl"] as const).forEach(pageId =>
|
await Promise.all(
|
||||||
buildContext.themeNames.map(themeName => {
|
(["register.ftl", "login-update-profile.ftl"] as const)
|
||||||
|
.map(pageId =>
|
||||||
|
buildContext.themeNames.map(async themeName => {
|
||||||
const ftlFilePath = pathJoin(
|
const ftlFilePath = pathJoin(
|
||||||
tmpResourcesDirPath,
|
tmpResourcesDirPath,
|
||||||
"theme",
|
"theme",
|
||||||
@ -145,6 +148,11 @@ export async function buildJar(params: {
|
|||||||
pageId
|
pageId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// NOTE: https://github.com/keycloakify/keycloakify/issues/665
|
||||||
|
if (!(await existsAsync(ftlFilePath))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
|
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
|
||||||
|
|
||||||
const ftlFileBasename = (() => {
|
const ftlFileBasename = (() => {
|
||||||
@ -164,11 +172,13 @@ export async function buildJar(params: {
|
|||||||
|
|
||||||
assert(modifiedFtlFileContent !== ftlFileContent);
|
assert(modifiedFtlFileContent !== ftlFileContent);
|
||||||
|
|
||||||
fs.writeFile(
|
await fs.writeFile(
|
||||||
pathJoin(pathDirname(ftlFilePath), ftlFileBasename),
|
pathJoin(pathDirname(ftlFilePath), ftlFileBasename),
|
||||||
Buffer.from(modifiedFtlFileContent, "utf8")
|
Buffer.from(modifiedFtlFileContent, "utf8")
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,7 +197,7 @@ export async function buildJar(params: {
|
|||||||
|
|
||||||
await new Promise<void>((resolve, reject) =>
|
await new Promise<void>((resolve, reject) =>
|
||||||
child_process.exec(
|
child_process.exec(
|
||||||
`mvn install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`,
|
`mvn clean install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`,
|
||||||
{ cwd: keycloakifyBuildCacheDirPath },
|
{ cwd: keycloakifyBuildCacheDirPath },
|
||||||
error => {
|
error => {
|
||||||
if (error !== null) {
|
if (error !== null) {
|
||||||
|
@ -87,6 +87,7 @@ attributes_to_attributesByName: {
|
|||||||
window.kcContext = kcContext;
|
window.kcContext = kcContext;
|
||||||
|
|
||||||
<#if xKeycloakify.themeType == "login" >
|
<#if xKeycloakify.themeType == "login" >
|
||||||
|
{
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.type = "importmap";
|
script.type = "importmap";
|
||||||
script.textContent = JSON.stringify({
|
script.textContent = JSON.stringify({
|
||||||
@ -96,6 +97,7 @@ window.kcContext = kcContext;
|
|||||||
}, null, 2);
|
}, null, 2);
|
||||||
|
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
</#if>
|
</#if>
|
||||||
|
|
||||||
function decodeHtmlEntities(htmlStr){
|
function decodeHtmlEntities(htmlStr){
|
||||||
@ -164,11 +166,8 @@ function decodeHtmlEntities(htmlStr){
|
|||||||
areSamePath(path, []) &&
|
areSamePath(path, []) &&
|
||||||
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(xKeycloakify.pageId)
|
["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"])
|
areSamePath(path, ["realm"])
|
||||||
) || (
|
|
||||||
"smtpConfig" == key &&
|
|
||||||
are_same_path(path, ["realm"])
|
|
||||||
) || (
|
) || (
|
||||||
xKeycloakify.pageId == "error.ftl" &&
|
xKeycloakify.pageId == "error.ftl" &&
|
||||||
areSamePath(path, ["realm"]) &&
|
areSamePath(path, ["realm"]) &&
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { type ThemeType, FALLBACK_LANGUAGE_TAG } from "../../shared/constants";
|
import { type ThemeType, FALLBACK_LANGUAGE_TAG } from "../../shared/constants";
|
||||||
import { crawl } from "../../tools/crawl";
|
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 { symToStr } from "tsafe/symToStr";
|
||||||
import * as recast from "recast";
|
import * as recast from "recast";
|
||||||
import * as babelParser from "@babel/parser";
|
import * as babelParser from "@babel/parser";
|
||||||
@ -10,12 +10,27 @@ import { escapeStringForPropertiesFile } from "../../tools/escapeStringForProper
|
|||||||
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
|
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { assert } from "tsafe/assert";
|
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: {
|
export function generateMessageProperties(params: {
|
||||||
themeSrcDirPath: string;
|
buildContext: BuildContextLike;
|
||||||
themeType: ThemeType;
|
themeType: Exclude<ThemeType, "admin">;
|
||||||
}): { languageTag: string; propertiesFileSource: string }[] {
|
}): {
|
||||||
const { themeSrcDirPath, themeType } = params;
|
languageTags: string[];
|
||||||
|
writeMessagePropertiesFiles: (params: {
|
||||||
|
messageDirPath: string;
|
||||||
|
themeName: string;
|
||||||
|
}) => void;
|
||||||
|
} {
|
||||||
|
const { buildContext, themeType } = params;
|
||||||
|
|
||||||
const baseMessagesDirPath = pathJoin(
|
const baseMessagesDirPath = pathJoin(
|
||||||
getThisCodebaseRootDirPath(),
|
getThisCodebaseRootDirPath(),
|
||||||
@ -25,20 +40,18 @@ export function generateMessageProperties(params: {
|
|||||||
"messages_defaultSet"
|
"messages_defaultSet"
|
||||||
);
|
);
|
||||||
|
|
||||||
const baseMessageBundle: { [languageTag: string]: Record<string, string> } =
|
const messages_defaultSet_by_languageTag_defaultSet: {
|
||||||
Object.fromEntries(
|
[languageTag_defaultSet: string]: Record<string, string>;
|
||||||
|
} = Object.fromEntries(
|
||||||
fs
|
fs
|
||||||
.readdirSync(baseMessagesDirPath)
|
.readdirSync(baseMessagesDirPath)
|
||||||
.filter(baseName => baseName !== "index.ts")
|
.filter(basename => basename !== "index.ts" && basename !== "types.ts")
|
||||||
.map(basename => ({
|
.map(basename => ({
|
||||||
languageTag: basename.replace(/\.ts$/, ""),
|
languageTag: basename.replace(/\.ts$/, ""),
|
||||||
filePath: pathJoin(baseMessagesDirPath, basename)
|
filePath: pathJoin(baseMessagesDirPath, basename)
|
||||||
}))
|
}))
|
||||||
.map(({ languageTag, filePath }) => {
|
.map(({ languageTag, filePath }) => {
|
||||||
const lines = fs
|
const lines = fs.readFileSync(filePath).toString("utf8").split(/\r?\n/);
|
||||||
.readFileSync(filePath)
|
|
||||||
.toString("utf8")
|
|
||||||
.split(/\r?\n/);
|
|
||||||
|
|
||||||
let messagesJson = "{";
|
let messagesJson = "{";
|
||||||
|
|
||||||
@ -69,7 +82,7 @@ export function generateMessageProperties(params: {
|
|||||||
|
|
||||||
const { i18nTsFilePath } = (() => {
|
const { i18nTsFilePath } = (() => {
|
||||||
let files = crawl({
|
let files = crawl({
|
||||||
dirPath: pathJoin(themeSrcDirPath, themeType),
|
dirPath: pathJoin(buildContext.themeSrcDirPath, themeType),
|
||||||
returnedPathsType: "absolute"
|
returnedPathsType: "absolute"
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -88,7 +101,7 @@ export function generateMessageProperties(params: {
|
|||||||
files = files.sort((a, b) => a.length - b.length);
|
files = files.sort((a, b) => a.length - b.length);
|
||||||
|
|
||||||
files = files.filter(file =>
|
files = files.filter(file =>
|
||||||
fs.readFileSync(file).toString("utf8").includes("createUseI18n(")
|
fs.readFileSync(file).toString("utf8").includes("i18nBuilder")
|
||||||
);
|
);
|
||||||
|
|
||||||
const i18nTsFilePath: string | undefined = files[0];
|
const i18nTsFilePath: string | undefined = files[0];
|
||||||
@ -96,13 +109,326 @@ export function generateMessageProperties(params: {
|
|||||||
return { i18nTsFilePath };
|
return { i18nTsFilePath };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const messageBundle: { [languageTag: string]: Record<string, string> } | undefined =
|
const i18nTsRoot = (() => {
|
||||||
(() => {
|
|
||||||
if (i18nTsFilePath === undefined) {
|
if (i18nTsFilePath === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
const root = recastParseTs(i18nTsFilePath);
|
||||||
|
return root;
|
||||||
|
})();
|
||||||
|
|
||||||
const root = recast.parse(fs.readFileSync(i18nTsFilePath).toString("utf8"), {
|
const messages_defaultSet_by_languageTag_notInDefaultSet:
|
||||||
|
| { [languageTag_notInDefaultSet: string]: Record<string, string> }
|
||||||
|
| undefined = (() => {
|
||||||
|
if (i18nTsRoot === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let extraLanguageEntryByLanguageTag: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; path: string }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // Stop traversing this path
|
||||||
|
}
|
||||||
|
|
||||||
|
this.traverse(path); // Continue traversing other paths
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
})()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return messages_defaultSet_by_languageTag_notInDefaultSet;
|
||||||
|
})();
|
||||||
|
|
||||||
|
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: {
|
parser: {
|
||||||
parse: (code: string) =>
|
parse: (code: string) =>
|
||||||
babelParser.parse(code, {
|
babelParser.parse(code, {
|
||||||
@ -113,80 +439,4 @@ export function generateMessageProperties(params: {
|
|||||||
types: babelTypes
|
types: babelTypes
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let messageBundleDeclarationTsCode: string | undefined = undefined;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.traverse(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(" ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageBundle;
|
|
||||||
})();
|
|
||||||
|
|
||||||
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 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 messageProperties;
|
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,56 @@
|
|||||||
import type { BuildContext } from "../../shared/buildContext";
|
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 fs from "fs";
|
||||||
import { rmSync } from "../../tools/fs.rmSync";
|
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,
|
||||||
|
THEME_TYPES
|
||||||
|
} 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 & {
|
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
|
||||||
|
BuildContextLike_generateMessageProperties & {
|
||||||
themeNames: string[];
|
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>();
|
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||||
@ -20,23 +61,474 @@ export async function generateResources(params: {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { resourcesDirPath, buildContext } = params;
|
const { resourcesDirPath, buildContext } = params;
|
||||||
|
|
||||||
const [themeName, ...themeVariantNames] = buildContext.themeNames;
|
const [themeName] = buildContext.themeNames;
|
||||||
|
|
||||||
if (fs.existsSync(resourcesDirPath)) {
|
if (fs.existsSync(resourcesDirPath)) {
|
||||||
rmSync(resourcesDirPath, { recursive: true });
|
rmSync(resourcesDirPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
await generateResourcesForMainTheme({
|
const getThemeTypeDirPath = (params: {
|
||||||
resourcesDirPath,
|
themeType: ThemeType | "email";
|
||||||
|
themeName: string;
|
||||||
|
}) => {
|
||||||
|
const { themeType, themeName } = params;
|
||||||
|
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeMessagePropertiesFilesByThemeType: Partial<
|
||||||
|
Record<ThemeType, (params: { messageDirPath: string; themeName: string }) => void>
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const themeType of THEME_TYPES) {
|
||||||
|
if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAccountThemeType = () => {
|
||||||
|
assert(themeType === "account");
|
||||||
|
|
||||||
|
assert(buildContext.implementedThemeTypes.account.isImplemented);
|
||||||
|
|
||||||
|
return buildContext.implementedThemeTypes.account.type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSpa = (() => {
|
||||||
|
switch (themeType) {
|
||||||
|
case "login":
|
||||||
|
return false;
|
||||||
|
case "account":
|
||||||
|
return getAccountThemeType() === "Single-Page";
|
||||||
|
case "admin":
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
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 !== "login" &&
|
||||||
|
buildContext.implementedThemeTypes.login.isImplemented
|
||||||
|
) {
|
||||||
|
// NOTE: We prevent doing it twice, it has been done for the login theme.
|
||||||
|
|
||||||
|
transformCodebase({
|
||||||
|
srcDirPath: pathJoin(
|
||||||
|
getThemeTypeDirPath({
|
||||||
themeName,
|
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
|
buildContext
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const themeVariantName of themeVariantNames) {
|
return {
|
||||||
generateResourcesForThemeVariant({
|
modifiedSourceCode: Buffer.from(fixedCssCode, "utf8")
|
||||||
resourcesDirPath,
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
themeName,
|
||||||
themeVariantName
|
indexHtmlCode: fs
|
||||||
|
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
|
||||||
|
.toString("utf8"),
|
||||||
|
buildContext,
|
||||||
|
keycloakifyVersion: readThisNpmPackageVersion(),
|
||||||
|
themeType,
|
||||||
|
fieldNames: isSpa
|
||||||
|
? []
|
||||||
|
: (assert(themeType !== "admin"),
|
||||||
|
readFieldNameUsage({
|
||||||
|
themeSrcDirPath: buildContext.themeSrcDirPath,
|
||||||
|
themeType
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
[
|
||||||
|
...(() => {
|
||||||
|
switch (themeType) {
|
||||||
|
case "login":
|
||||||
|
return LOGIN_THEME_PAGE_IDS;
|
||||||
|
case "account":
|
||||||
|
return getAccountThemeType() === "Single-Page"
|
||||||
|
? ["index.ftl"]
|
||||||
|
: ACCOUNT_THEME_PAGE_IDS;
|
||||||
|
case "admin":
|
||||||
|
return ["index.ftl"];
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
...(isSpa
|
||||||
|
? []
|
||||||
|
: 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 (isSpa) {
|
||||||
|
break i18n_messages_generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(themeType !== "admin");
|
||||||
|
|
||||||
|
const wrap = generateMessageProperties({
|
||||||
|
buildContext,
|
||||||
|
themeType
|
||||||
|
});
|
||||||
|
|
||||||
|
languageTags = wrap.languageTags;
|
||||||
|
const { writeMessagePropertiesFiles } = wrap;
|
||||||
|
|
||||||
|
writeMessagePropertiesFilesByThemeType[themeType] =
|
||||||
|
writeMessagePropertiesFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
bring_in_spas_messages: {
|
||||||
|
if (!isSpa) {
|
||||||
|
break bring_in_spas_messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(themeType !== "login");
|
||||||
|
|
||||||
|
const accountUiDirPath = child_process
|
||||||
|
.execSync(`npm list @keycloakify/keycloak-${themeType}-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 }),
|
||||||
|
"messages"
|
||||||
|
);
|
||||||
|
|
||||||
|
transformCodebase({
|
||||||
|
srcDirPath: messageDirPath_defaults,
|
||||||
|
destDirPath: messagesDirPath_dest
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_theme_changes: {
|
||||||
|
const messagesDirPath_theme = pathJoin(
|
||||||
|
buildContext.themeSrcDirPath,
|
||||||
|
themeType,
|
||||||
|
"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 (isSpa) {
|
||||||
|
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":
|
||||||
|
switch (getAccountThemeType()) {
|
||||||
|
case "Multi-Page":
|
||||||
|
return "account-v1";
|
||||||
|
case "Single-Page":
|
||||||
|
return "base";
|
||||||
|
}
|
||||||
|
case "login":
|
||||||
|
return "keycloak";
|
||||||
|
case "admin":
|
||||||
|
return "base";
|
||||||
|
}
|
||||||
|
assert<Equals<typeof themeType, never>>(false);
|
||||||
|
})()}`,
|
||||||
|
...(themeType === "account" && getAccountThemeType() === "Single-Page"
|
||||||
|
? ["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"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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.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,
|
|
||||||
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.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)}}`
|
|
||||||
)
|
|
||||||
].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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
@ -7,7 +7,7 @@ import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPa
|
|||||||
/** Assumes the theme type exists */
|
/** Assumes the theme type exists */
|
||||||
export function readFieldNameUsage(params: {
|
export function readFieldNameUsage(params: {
|
||||||
themeSrcDirPath: string;
|
themeSrcDirPath: string;
|
||||||
themeType: ThemeType;
|
themeType: Exclude<ThemeType, "admin">;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const { themeSrcDirPath, themeType } = params;
|
const { themeSrcDirPath, themeType } = params;
|
||||||
|
|
||||||
|
@ -2,19 +2,16 @@ import { generateResources } from "./generateResources";
|
|||||||
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
|
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
|
||||||
import * as child_process from "child_process";
|
import * as child_process from "child_process";
|
||||||
import * as fs from "fs";
|
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 { VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES } from "../shared/constants";
|
||||||
import { buildJars } from "./buildJars";
|
import { buildJars } from "./buildJars";
|
||||||
import type { CliCommandOptions } from "../main";
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
|
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
|
||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
import { rmSync } from "../tools/fs.rmSync";
|
import { rmSync } from "../tools/fs.rmSync";
|
||||||
|
|
||||||
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
|
export async function command(params: { buildContext: BuildContext }) {
|
||||||
const { cliCommandOptions } = params;
|
const { buildContext } = params;
|
||||||
|
|
||||||
const buildContext = getBuildContext({ cliCommandOptions });
|
|
||||||
|
|
||||||
exit_if_maven_not_installed: {
|
exit_if_maven_not_installed: {
|
||||||
let commandOutput: Buffer | undefined = undefined;
|
let commandOutput: Buffer | undefined = undefined;
|
||||||
|
@ -4,8 +4,9 @@ import { termost } from "termost";
|
|||||||
import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
|
import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
|
||||||
import * as child_process from "child_process";
|
import * as child_process from "child_process";
|
||||||
import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx";
|
import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx";
|
||||||
|
import { getBuildContext } from "./shared/buildContext";
|
||||||
|
|
||||||
export type CliCommandOptions = {
|
type CliCommandOptions = {
|
||||||
projectDirPath: string | undefined;
|
projectDirPath: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -69,10 +70,10 @@ program
|
|||||||
})
|
})
|
||||||
.task({
|
.task({
|
||||||
skip,
|
skip,
|
||||||
handler: async cliCommandOptions => {
|
handler: async ({ projectDirPath }) => {
|
||||||
const { command } = await import("./keycloakify");
|
const { command } = await import("./keycloakify");
|
||||||
|
|
||||||
await command({ cliCommandOptions });
|
await command({ buildContext: getBuildContext({ projectDirPath }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -130,10 +131,13 @@ program
|
|||||||
})
|
})
|
||||||
.task({
|
.task({
|
||||||
skip,
|
skip,
|
||||||
handler: async cliCommandOptions => {
|
handler: async ({ projectDirPath, keycloakVersion, port, realmJsonFilePath }) => {
|
||||||
const { command } = await import("./start-keycloak");
|
const { command } = await import("./start-keycloak");
|
||||||
|
|
||||||
await command({ cliCommandOptions });
|
await command({
|
||||||
|
buildContext: getBuildContext({ projectDirPath }),
|
||||||
|
cliCommandOptions: { keycloakVersion, port, realmJsonFilePath }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -144,10 +148,10 @@ program
|
|||||||
})
|
})
|
||||||
.task({
|
.task({
|
||||||
skip,
|
skip,
|
||||||
handler: async cliCommandOptions => {
|
handler: async ({ projectDirPath }) => {
|
||||||
const { command } = await import("./eject-page");
|
const { command } = await import("./eject-page");
|
||||||
|
|
||||||
await command({ cliCommandOptions });
|
await command({ buildContext: getBuildContext({ projectDirPath }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -158,10 +162,10 @@ program
|
|||||||
})
|
})
|
||||||
.task({
|
.task({
|
||||||
skip,
|
skip,
|
||||||
handler: async cliCommandOptions => {
|
handler: async ({ projectDirPath }) => {
|
||||||
const { command } = await import("./add-story");
|
const { command } = await import("./add-story");
|
||||||
|
|
||||||
await command({ cliCommandOptions });
|
await command({ buildContext: getBuildContext({ projectDirPath }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -172,10 +176,10 @@ program
|
|||||||
})
|
})
|
||||||
.task({
|
.task({
|
||||||
skip,
|
skip,
|
||||||
handler: async cliCommandOptions => {
|
handler: async ({ projectDirPath }) => {
|
||||||
const { command } = await import("./initialize-email-theme");
|
const { command } = await import("./initialize-email-theme");
|
||||||
|
|
||||||
await command({ cliCommandOptions });
|
await command({ buildContext: getBuildContext({ projectDirPath }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -186,10 +190,10 @@ program
|
|||||||
})
|
})
|
||||||
.task({
|
.task({
|
||||||
skip,
|
skip,
|
||||||
handler: async cliCommandOptions => {
|
handler: async ({ projectDirPath }) => {
|
||||||
const { command } = await import("./initialize-account-theme");
|
const { command } = await import("./initialize-account-theme");
|
||||||
|
|
||||||
await command({ cliCommandOptions });
|
await command({ buildContext: getBuildContext({ projectDirPath }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -201,10 +205,10 @@ program
|
|||||||
})
|
})
|
||||||
.task({
|
.task({
|
||||||
skip,
|
skip,
|
||||||
handler: async cliCommandOptions => {
|
handler: async ({ projectDirPath }) => {
|
||||||
const { command } = await import("./copy-keycloak-resources-to-public");
|
const { command } = await import("./copy-keycloak-resources-to-public");
|
||||||
|
|
||||||
await command({ cliCommandOptions });
|
await command({ buildContext: getBuildContext({ projectDirPath }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -216,10 +220,60 @@ program
|
|||||||
})
|
})
|
||||||
.task({
|
.task({
|
||||||
skip,
|
skip,
|
||||||
handler: async cliCommandOptions => {
|
handler: async ({ projectDirPath }) => {
|
||||||
const { command } = await import("./update-kc-gen");
|
const { command } = await import("./update-kc-gen");
|
||||||
|
|
||||||
await command({ cliCommandOptions });
|
await command({ buildContext: getBuildContext({ projectDirPath }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command({
|
||||||
|
name: "postinstall",
|
||||||
|
description: "Initialize all the Keycloakify UI modules installed in the project."
|
||||||
|
})
|
||||||
|
.task({
|
||||||
|
skip,
|
||||||
|
handler: async ({ projectDirPath }) => {
|
||||||
|
const { command } = await import("./postinstall");
|
||||||
|
|
||||||
|
await command({ buildContext: getBuildContext({ projectDirPath }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command<{
|
||||||
|
file: string;
|
||||||
|
}>({
|
||||||
|
name: "eject-file",
|
||||||
|
description: [
|
||||||
|
"WARNING: Not usable yet, will be used for future features",
|
||||||
|
"Take ownership over a given file"
|
||||||
|
].join(" ")
|
||||||
|
})
|
||||||
|
.option({
|
||||||
|
key: "file",
|
||||||
|
name: (() => {
|
||||||
|
const name = "file";
|
||||||
|
|
||||||
|
optionsKeys.push(name);
|
||||||
|
|
||||||
|
return name;
|
||||||
|
})(),
|
||||||
|
description: [
|
||||||
|
"Relative path of the file relative to the directory of your keycloak theme source",
|
||||||
|
"Example `--file src/login/page/Login.tsx`"
|
||||||
|
].join(" ")
|
||||||
|
})
|
||||||
|
.task({
|
||||||
|
skip,
|
||||||
|
handler: async ({ projectDirPath, file }) => {
|
||||||
|
const { command } = await import("./eject-file");
|
||||||
|
|
||||||
|
await command({
|
||||||
|
buildContext: getBuildContext({ projectDirPath }),
|
||||||
|
cliCommandOptions: { file }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -0,0 +1,82 @@
|
|||||||
|
import { getIsPrettierAvailable, runPrettier } from "../tools/runPrettier";
|
||||||
|
import * as fsPr from "fs/promises";
|
||||||
|
import { join as pathJoin, sep as pathSep } from "path";
|
||||||
|
import { assert } from "tsafe/assert";
|
||||||
|
import type { BuildContext } from "../shared/buildContext";
|
||||||
|
import { KEYCLOAK_THEME } from "../shared/constants";
|
||||||
|
|
||||||
|
export type BuildContextLike = {
|
||||||
|
themeSrcDirPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||||
|
|
||||||
|
export async function getUiModuleFileSourceCodeReadyToBeCopied(params: {
|
||||||
|
buildContext: BuildContextLike;
|
||||||
|
fileRelativePath: string;
|
||||||
|
isForEjection: boolean;
|
||||||
|
uiModuleDirPath: string;
|
||||||
|
uiModuleName: string;
|
||||||
|
uiModuleVersion: string;
|
||||||
|
}): Promise<Buffer> {
|
||||||
|
const {
|
||||||
|
buildContext,
|
||||||
|
uiModuleDirPath,
|
||||||
|
fileRelativePath,
|
||||||
|
isForEjection,
|
||||||
|
uiModuleName,
|
||||||
|
uiModuleVersion
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
let sourceCode = (
|
||||||
|
await fsPr.readFile(pathJoin(uiModuleDirPath, KEYCLOAK_THEME, fileRelativePath))
|
||||||
|
).toString("utf8");
|
||||||
|
|
||||||
|
const toComment = (lines: string[]) => {
|
||||||
|
for (const ext of [".ts", ".tsx", ".css", ".less", ".sass", ".js", ".jsx"]) {
|
||||||
|
if (!fileRelativePath.endsWith(ext)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [`/**`, ...lines.map(line => ` * ${line}`), ` */`].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileRelativePath.endsWith(".html")) {
|
||||||
|
return [`<!--`, ...lines.map(line => ` ${line}`), `-->`].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const comment = toComment(
|
||||||
|
isForEjection
|
||||||
|
? [`This file was ejected from ${uiModuleName} version ${uiModuleVersion}.`]
|
||||||
|
: [
|
||||||
|
`WARNING: Before modifying this file run the following command:`,
|
||||||
|
``,
|
||||||
|
`$ npx keycloakify eject-file --file ${fileRelativePath.split(pathSep).join("/")}`,
|
||||||
|
``,
|
||||||
|
`This file comes from ${uiModuleName} version ${uiModuleVersion}.`,
|
||||||
|
`This file has been copied over to your repo by your postinstall script: \`npx keycloakify postinstall\``
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (comment !== undefined) {
|
||||||
|
sourceCode = [comment, ``, sourceCode].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const destFilePath = pathJoin(buildContext.themeSrcDirPath, fileRelativePath);
|
||||||
|
|
||||||
|
format: {
|
||||||
|
if (!(await getIsPrettierAvailable())) {
|
||||||
|
break format;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceCode = await runPrettier({
|
||||||
|
filePath: destFilePath,
|
||||||
|
sourceCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(sourceCode, "utf8");
|
||||||
|
}
|
1
src/bin/postinstall/index.ts
Normal file
1
src/bin/postinstall/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./postinstall";
|
158
src/bin/postinstall/installUiModulesPeerDependencies.ts
Normal file
158
src/bin/postinstall/installUiModulesPeerDependencies.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { assert, type Equals } from "tsafe/assert";
|
||||||
|
import { is } from "tsafe/is";
|
||||||
|
import type { BuildContext } from "../shared/buildContext";
|
||||||
|
import type { UiModuleMeta } from "./uiModuleMeta";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { id } from "tsafe/id";
|
||||||
|
import * as fsPr from "fs/promises";
|
||||||
|
import { SemVer } from "../tools/SemVer";
|
||||||
|
import { same } from "evt/tools/inDepth/same";
|
||||||
|
import { runPrettier, getIsPrettierAvailable } from "../tools/runPrettier";
|
||||||
|
import { npmInstall } from "../tools/npmInstall";
|
||||||
|
import { dirname as pathDirname } from "path";
|
||||||
|
|
||||||
|
export type BuildContextLike = {
|
||||||
|
packageJsonFilePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||||
|
|
||||||
|
export type UiModuleMetaLike = {
|
||||||
|
moduleName: string;
|
||||||
|
peerDependencies: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert<UiModuleMeta extends UiModuleMetaLike ? true : false>();
|
||||||
|
|
||||||
|
export async function installUiModulesPeerDependencies(params: {
|
||||||
|
buildContext: BuildContextLike;
|
||||||
|
uiModuleMetas: UiModuleMetaLike[];
|
||||||
|
}): Promise<void | never> {
|
||||||
|
const { buildContext, uiModuleMetas } = params;
|
||||||
|
|
||||||
|
const { uiModulesPerDependencies } = (() => {
|
||||||
|
const uiModulesPerDependencies: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const { peerDependencies } of uiModuleMetas) {
|
||||||
|
for (const [peerDependencyName, versionRange_candidate] of Object.entries(
|
||||||
|
peerDependencies
|
||||||
|
)) {
|
||||||
|
const versionRange = (() => {
|
||||||
|
const versionRange_current =
|
||||||
|
uiModulesPerDependencies[peerDependencyName];
|
||||||
|
|
||||||
|
if (versionRange_current === undefined) {
|
||||||
|
return versionRange_candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionRange_current === "*") {
|
||||||
|
return versionRange_candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionRange_candidate === "*") {
|
||||||
|
return versionRange_current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { versionRange } = [
|
||||||
|
versionRange_current,
|
||||||
|
versionRange_candidate
|
||||||
|
]
|
||||||
|
.map(versionRange => ({
|
||||||
|
versionRange,
|
||||||
|
semVer: SemVer.parse(
|
||||||
|
(() => {
|
||||||
|
if (
|
||||||
|
versionRange.startsWith("^") ||
|
||||||
|
versionRange.startsWith("~")
|
||||||
|
) {
|
||||||
|
return versionRange.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return versionRange;
|
||||||
|
})()
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => SemVer.compare(b.semVer, a.semVer))[0];
|
||||||
|
|
||||||
|
return versionRange;
|
||||||
|
})();
|
||||||
|
|
||||||
|
uiModulesPerDependencies[peerDependencyName] = versionRange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uiModulesPerDependencies };
|
||||||
|
})();
|
||||||
|
|
||||||
|
const parsedPackageJson = await (async () => {
|
||||||
|
type ParsedPackageJson = {
|
||||||
|
dependencies?: Record<string, string>;
|
||||||
|
devDependencies?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zParsedPackageJson = (() => {
|
||||||
|
type TargetType = ParsedPackageJson;
|
||||||
|
|
||||||
|
const zParsedPackageJson = z.object({
|
||||||
|
dependencies: z.record(z.string()).optional(),
|
||||||
|
devDependencies: z.record(z.string()).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
type InferredType = z.infer<typeof zParsedPackageJson>;
|
||||||
|
|
||||||
|
assert<Equals<InferredType, TargetType>>();
|
||||||
|
|
||||||
|
return id<z.ZodType<TargetType>>(zParsedPackageJson);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const parsedPackageJson = JSON.parse(
|
||||||
|
(await fsPr.readFile(buildContext.packageJsonFilePath)).toString("utf8")
|
||||||
|
);
|
||||||
|
|
||||||
|
zParsedPackageJson.parse(parsedPackageJson);
|
||||||
|
|
||||||
|
assert(is<ParsedPackageJson>(parsedPackageJson));
|
||||||
|
|
||||||
|
return parsedPackageJson;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const parsedPackageJson_before = JSON.parse(JSON.stringify(parsedPackageJson));
|
||||||
|
|
||||||
|
for (const [moduleName, versionRange] of Object.entries(uiModulesPerDependencies)) {
|
||||||
|
if (moduleName.startsWith("@types/")) {
|
||||||
|
(parsedPackageJson.devDependencies ??= {})[moduleName] = versionRange;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedPackageJson.devDependencies !== undefined) {
|
||||||
|
delete parsedPackageJson.devDependencies[moduleName];
|
||||||
|
}
|
||||||
|
|
||||||
|
(parsedPackageJson.dependencies ??= {})[moduleName] = versionRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (same(parsedPackageJson, parsedPackageJson_before)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let packageJsonContentStr = JSON.stringify(parsedPackageJson, null, 2);
|
||||||
|
|
||||||
|
format: {
|
||||||
|
if (!(await getIsPrettierAvailable())) {
|
||||||
|
break format;
|
||||||
|
}
|
||||||
|
|
||||||
|
packageJsonContentStr = await runPrettier({
|
||||||
|
sourceCode: packageJsonContentStr,
|
||||||
|
filePath: buildContext.packageJsonFilePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsPr.writeFile(buildContext.packageJsonFilePath, packageJsonContentStr);
|
||||||
|
|
||||||
|
npmInstall({
|
||||||
|
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
|
||||||
|
});
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
136
src/bin/postinstall/managedGitignoreFile.ts
Normal file
136
src/bin/postinstall/managedGitignoreFile.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import * as fsPr from "fs/promises";
|
||||||
|
import {
|
||||||
|
join as pathJoin,
|
||||||
|
sep as pathSep,
|
||||||
|
dirname as pathDirname,
|
||||||
|
relative as pathRelative
|
||||||
|
} from "path";
|
||||||
|
import { assert } from "tsafe/assert";
|
||||||
|
import type { BuildContext } from "../shared/buildContext";
|
||||||
|
import type { UiModuleMeta } from "./uiModuleMeta";
|
||||||
|
import { existsAsync } from "../tools/fs.existsAsync";
|
||||||
|
import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath";
|
||||||
|
|
||||||
|
export type BuildContextLike = {
|
||||||
|
themeSrcDirPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||||
|
|
||||||
|
const DELIMITER_START = `# === Ejected files start ===`;
|
||||||
|
const DELIMITER_END = `# === Ejected files end =====`;
|
||||||
|
|
||||||
|
export async function writeManagedGitignoreFile(params: {
|
||||||
|
buildContext: BuildContextLike;
|
||||||
|
uiModuleMetas: UiModuleMeta[];
|
||||||
|
ejectedFilesRelativePaths: string[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const { buildContext, uiModuleMetas, ejectedFilesRelativePaths } = params;
|
||||||
|
|
||||||
|
if (uiModuleMetas.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = pathJoin(buildContext.themeSrcDirPath, ".gitignore");
|
||||||
|
|
||||||
|
const content_new = Buffer.from(
|
||||||
|
[
|
||||||
|
`# This file is managed by Keycloakify, do not edit it manually.`,
|
||||||
|
``,
|
||||||
|
DELIMITER_START,
|
||||||
|
...ejectedFilesRelativePaths
|
||||||
|
.map(fileRelativePath => fileRelativePath.split(pathSep).join("/"))
|
||||||
|
.map(line => `# ${line}`),
|
||||||
|
DELIMITER_END,
|
||||||
|
``,
|
||||||
|
...uiModuleMetas
|
||||||
|
.map(uiModuleMeta => [
|
||||||
|
`# === ${uiModuleMeta.moduleName} v${uiModuleMeta.version} ===`,
|
||||||
|
...uiModuleMeta.files
|
||||||
|
.map(({ fileRelativePath }) => fileRelativePath)
|
||||||
|
.filter(
|
||||||
|
fileRelativePath =>
|
||||||
|
!ejectedFilesRelativePaths.includes(fileRelativePath)
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
fileRelativePath =>
|
||||||
|
`/${fileRelativePath.split(pathSep).join("/").replace(/^\.\//, "")}`
|
||||||
|
),
|
||||||
|
|
||||||
|
``
|
||||||
|
])
|
||||||
|
.flat()
|
||||||
|
].join("\n"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const content_current = await (async () => {
|
||||||
|
if (!(await existsAsync(filePath))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await fsPr.readFile(filePath);
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (content_current !== undefined && content_current.equals(content_new)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
create_dir: {
|
||||||
|
const dirPath = pathDirname(filePath);
|
||||||
|
|
||||||
|
if (await existsAsync(dirPath)) {
|
||||||
|
break create_dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsPr.mkdir(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsPr.writeFile(filePath, content_new);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readManagedGitignoreFile(params: {
|
||||||
|
buildContext: BuildContextLike;
|
||||||
|
}): Promise<{
|
||||||
|
ejectedFilesRelativePaths: string[];
|
||||||
|
}> {
|
||||||
|
const { buildContext } = params;
|
||||||
|
|
||||||
|
const filePath = pathJoin(buildContext.themeSrcDirPath, ".gitignore");
|
||||||
|
|
||||||
|
if (!(await existsAsync(filePath))) {
|
||||||
|
return { ejectedFilesRelativePaths: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentStr = (await fsPr.readFile(filePath)).toString("utf8");
|
||||||
|
|
||||||
|
const payload = (() => {
|
||||||
|
const index_start = contentStr.indexOf(DELIMITER_START);
|
||||||
|
const index_end = contentStr.indexOf(DELIMITER_END);
|
||||||
|
|
||||||
|
if (index_start === -1 || index_end === -1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentStr.slice(index_start + DELIMITER_START.length, index_end).trim();
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (payload === undefined) {
|
||||||
|
return { ejectedFilesRelativePaths: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ejectedFilesRelativePaths = payload
|
||||||
|
.split("\n")
|
||||||
|
.map(line => line.trim())
|
||||||
|
.map(line => line.replace(/^# /, ""))
|
||||||
|
.filter(line => line !== "")
|
||||||
|
.map(line =>
|
||||||
|
getAbsoluteAndInOsFormatPath({
|
||||||
|
cwd: buildContext.themeSrcDirPath,
|
||||||
|
pathIsh: line
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.map(filePath => pathRelative(buildContext.themeSrcDirPath, filePath));
|
||||||
|
|
||||||
|
return { ejectedFilesRelativePaths };
|
||||||
|
}
|
101
src/bin/postinstall/postinstall.ts
Normal file
101
src/bin/postinstall/postinstall.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import type { BuildContext } from "../shared/buildContext";
|
||||||
|
import { getUiModuleMetas, computeHash } from "./uiModuleMeta";
|
||||||
|
import { installUiModulesPeerDependencies } from "./installUiModulesPeerDependencies";
|
||||||
|
import {
|
||||||
|
readManagedGitignoreFile,
|
||||||
|
writeManagedGitignoreFile
|
||||||
|
} from "./managedGitignoreFile";
|
||||||
|
import { dirname as pathDirname } from "path";
|
||||||
|
import { join as pathJoin } from "path";
|
||||||
|
import { existsAsync } from "../tools/fs.existsAsync";
|
||||||
|
import * as fsPr from "fs/promises";
|
||||||
|
import { getIsTrackedByGit } from "../tools/isTrackedByGit";
|
||||||
|
import { untrackFromGit } from "../tools/untrackFromGit";
|
||||||
|
|
||||||
|
export async function command(params: { buildContext: BuildContext }) {
|
||||||
|
const { buildContext } = params;
|
||||||
|
|
||||||
|
const uiModuleMetas = await getUiModuleMetas({ buildContext });
|
||||||
|
|
||||||
|
await installUiModulesPeerDependencies({
|
||||||
|
buildContext,
|
||||||
|
uiModuleMetas
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ejectedFilesRelativePaths } = await readManagedGitignoreFile({
|
||||||
|
buildContext
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeManagedGitignoreFile({
|
||||||
|
buildContext,
|
||||||
|
ejectedFilesRelativePaths,
|
||||||
|
uiModuleMetas
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
uiModuleMetas
|
||||||
|
.map(uiModuleMeta =>
|
||||||
|
Promise.all(
|
||||||
|
uiModuleMeta.files.map(
|
||||||
|
async ({ fileRelativePath, copyableFilePath, hash }) => {
|
||||||
|
if (ejectedFilesRelativePaths.includes(fileRelativePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destFilePath = pathJoin(
|
||||||
|
buildContext.themeSrcDirPath,
|
||||||
|
fileRelativePath
|
||||||
|
);
|
||||||
|
|
||||||
|
const doesFileExist = await existsAsync(destFilePath);
|
||||||
|
|
||||||
|
skip_condition: {
|
||||||
|
if (!doesFileExist) {
|
||||||
|
break skip_condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destFileHash = computeHash(
|
||||||
|
await fsPr.readFile(destFilePath)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (destFileHash !== hash) {
|
||||||
|
break skip_condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
git_untrack: {
|
||||||
|
if (!doesFileExist) {
|
||||||
|
break git_untrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTracked = await getIsTrackedByGit({
|
||||||
|
filePath: destFilePath
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isTracked) {
|
||||||
|
break git_untrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
await untrackFromGit({
|
||||||
|
filePath: destFilePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const dirName = pathDirname(destFilePath);
|
||||||
|
|
||||||
|
if (!(await existsAsync(dirName))) {
|
||||||
|
await fsPr.mkdir(dirName, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsPr.copyFile(copyableFilePath, destFilePath);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
);
|
||||||
|
}
|
309
src/bin/postinstall/uiModuleMeta.ts
Normal file
309
src/bin/postinstall/uiModuleMeta.ts
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
import { assert, type Equals } from "tsafe/assert";
|
||||||
|
import { id } from "tsafe/id";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { join as pathJoin, dirname as pathDirname } from "path";
|
||||||
|
import * as fsPr from "fs/promises";
|
||||||
|
import type { BuildContext } from "../shared/buildContext";
|
||||||
|
import { is } from "tsafe/is";
|
||||||
|
import { existsAsync } from "../tools/fs.existsAsync";
|
||||||
|
import { listInstalledModules } from "../tools/listInstalledModules";
|
||||||
|
import { crawlAsync } from "../tools/crawlAsync";
|
||||||
|
import { getIsPrettierAvailable, getPrettier } from "../tools/runPrettier";
|
||||||
|
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
|
||||||
|
import {
|
||||||
|
getUiModuleFileSourceCodeReadyToBeCopied,
|
||||||
|
type BuildContextLike as BuildContextLike_getUiModuleFileSourceCodeReadyToBeCopied
|
||||||
|
} from "./getUiModuleFileSourceCodeReadyToBeCopied";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
import { KEYCLOAK_THEME } from "../shared/constants";
|
||||||
|
import { exclude } from "tsafe/exclude";
|
||||||
|
|
||||||
|
export type UiModuleMeta = {
|
||||||
|
moduleName: string;
|
||||||
|
version: string;
|
||||||
|
files: {
|
||||||
|
fileRelativePath: string;
|
||||||
|
hash: string;
|
||||||
|
copyableFilePath: string;
|
||||||
|
}[];
|
||||||
|
peerDependencies: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zUiModuleMeta = (() => {
|
||||||
|
type ExpectedType = UiModuleMeta;
|
||||||
|
|
||||||
|
const zTargetType = z.object({
|
||||||
|
moduleName: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
files: z.array(
|
||||||
|
z.object({
|
||||||
|
fileRelativePath: z.string(),
|
||||||
|
hash: z.string(),
|
||||||
|
copyableFilePath: z.string()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
peerDependencies: z.record(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
type InferredType = z.infer<typeof zTargetType>;
|
||||||
|
|
||||||
|
assert<Equals<InferredType, ExpectedType>>();
|
||||||
|
|
||||||
|
return id<z.ZodType<ExpectedType>>(zTargetType);
|
||||||
|
})();
|
||||||
|
|
||||||
|
type ParsedCacheFile = {
|
||||||
|
keycloakifyVersion: string;
|
||||||
|
prettierConfigHash: string | null;
|
||||||
|
thisFilePath: string;
|
||||||
|
uiModuleMetas: UiModuleMeta[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const zParsedCacheFile = (() => {
|
||||||
|
type ExpectedType = ParsedCacheFile;
|
||||||
|
|
||||||
|
const zTargetType = z.object({
|
||||||
|
keycloakifyVersion: z.string(),
|
||||||
|
prettierConfigHash: z.union([z.string(), z.null()]),
|
||||||
|
thisFilePath: z.string(),
|
||||||
|
uiModuleMetas: z.array(zUiModuleMeta)
|
||||||
|
});
|
||||||
|
|
||||||
|
type InferredType = z.infer<typeof zTargetType>;
|
||||||
|
|
||||||
|
assert<Equals<InferredType, ExpectedType>>();
|
||||||
|
|
||||||
|
return id<z.ZodType<ExpectedType>>(zTargetType);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const CACHE_FILE_RELATIVE_PATH = pathJoin("ui-modules", "cache.json");
|
||||||
|
|
||||||
|
export type BuildContextLike =
|
||||||
|
BuildContextLike_getUiModuleFileSourceCodeReadyToBeCopied & {
|
||||||
|
cacheDirPath: string;
|
||||||
|
packageJsonFilePath: string;
|
||||||
|
projectDirPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||||
|
|
||||||
|
export async function getUiModuleMetas(params: {
|
||||||
|
buildContext: BuildContextLike;
|
||||||
|
}): Promise<UiModuleMeta[]> {
|
||||||
|
const { buildContext } = params;
|
||||||
|
|
||||||
|
const cacheFilePath = pathJoin(buildContext.cacheDirPath, CACHE_FILE_RELATIVE_PATH);
|
||||||
|
|
||||||
|
const keycloakifyVersion = readThisNpmPackageVersion();
|
||||||
|
|
||||||
|
const prettierConfigHash = await (async () => {
|
||||||
|
if (!(await getIsPrettierAvailable())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { configHash } = await getPrettier();
|
||||||
|
|
||||||
|
return configHash;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const installedUiModules = await (async () => {
|
||||||
|
const installedModulesWithKeycloakifyInTheName = await listInstalledModules({
|
||||||
|
packageJsonFilePath: buildContext.packageJsonFilePath,
|
||||||
|
projectDirPath: buildContext.packageJsonFilePath,
|
||||||
|
filter: ({ moduleName }) =>
|
||||||
|
moduleName.includes("keycloakify") && moduleName !== "keycloakify"
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
await Promise.all(
|
||||||
|
installedModulesWithKeycloakifyInTheName.map(async entry => {
|
||||||
|
if (!(await existsAsync(pathJoin(entry.dirPath, KEYCLOAK_THEME)))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).filter(exclude(undefined));
|
||||||
|
})();
|
||||||
|
|
||||||
|
const cacheContent = await (async () => {
|
||||||
|
if (!(await existsAsync(cacheFilePath))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await fsPr.readFile(cacheFilePath);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const uiModuleMetas_cacheUpToDate: UiModuleMeta[] = await (async () => {
|
||||||
|
const parsedCacheFile: ParsedCacheFile | undefined = await (async () => {
|
||||||
|
if (cacheContent === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheContentStr = cacheContent.toString("utf8");
|
||||||
|
|
||||||
|
let parsedCacheFile: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedCacheFile = JSON.parse(cacheContentStr);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
zParsedCacheFile.parse(parsedCacheFile);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(is<ParsedCacheFile>(parsedCacheFile));
|
||||||
|
|
||||||
|
return parsedCacheFile;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (parsedCacheFile === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedCacheFile.keycloakifyVersion !== keycloakifyVersion) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedCacheFile.prettierConfigHash !== prettierConfigHash) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedCacheFile.thisFilePath !== cacheFilePath) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiModuleMetas_cacheUpToDate = parsedCacheFile.uiModuleMetas.filter(
|
||||||
|
uiModuleMeta => {
|
||||||
|
const correspondingInstalledUiModule = installedUiModules.find(
|
||||||
|
installedUiModule =>
|
||||||
|
installedUiModule.moduleName === uiModuleMeta.moduleName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (correspondingInstalledUiModule === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return correspondingInstalledUiModule.version === uiModuleMeta.version;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return uiModuleMetas_cacheUpToDate;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const uiModuleMetas = await Promise.all(
|
||||||
|
installedUiModules.map(
|
||||||
|
async ({
|
||||||
|
moduleName,
|
||||||
|
version,
|
||||||
|
peerDependencies,
|
||||||
|
dirPath
|
||||||
|
}): Promise<UiModuleMeta> => {
|
||||||
|
use_cache: {
|
||||||
|
const uiModuleMeta_cache = uiModuleMetas_cacheUpToDate.find(
|
||||||
|
uiModuleMeta => uiModuleMeta.moduleName === moduleName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uiModuleMeta_cache === undefined) {
|
||||||
|
break use_cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uiModuleMeta_cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: UiModuleMeta["files"] = [];
|
||||||
|
|
||||||
|
{
|
||||||
|
const srcDirPath = pathJoin(dirPath, KEYCLOAK_THEME);
|
||||||
|
|
||||||
|
await crawlAsync({
|
||||||
|
dirPath: srcDirPath,
|
||||||
|
returnedPathsType: "relative to dirPath",
|
||||||
|
onFileFound: async fileRelativePath => {
|
||||||
|
const sourceCode =
|
||||||
|
await getUiModuleFileSourceCodeReadyToBeCopied({
|
||||||
|
buildContext,
|
||||||
|
fileRelativePath,
|
||||||
|
isForEjection: false,
|
||||||
|
uiModuleDirPath: dirPath,
|
||||||
|
uiModuleName: moduleName,
|
||||||
|
uiModuleVersion: version
|
||||||
|
});
|
||||||
|
|
||||||
|
const hash = computeHash(sourceCode);
|
||||||
|
|
||||||
|
const copyableFilePath = pathJoin(
|
||||||
|
pathDirname(cacheFilePath),
|
||||||
|
KEYCLOAK_THEME,
|
||||||
|
fileRelativePath
|
||||||
|
);
|
||||||
|
|
||||||
|
{
|
||||||
|
const dirPath = pathDirname(copyableFilePath);
|
||||||
|
|
||||||
|
if (!(await existsAsync(dirPath))) {
|
||||||
|
await fsPr.mkdir(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fsPr.writeFile(copyableFilePath, sourceCode);
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
fileRelativePath,
|
||||||
|
hash,
|
||||||
|
copyableFilePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return id<UiModuleMeta>({
|
||||||
|
moduleName,
|
||||||
|
version,
|
||||||
|
files,
|
||||||
|
peerDependencies
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
update_cache: {
|
||||||
|
const parsedCacheFile = id<ParsedCacheFile>({
|
||||||
|
keycloakifyVersion,
|
||||||
|
prettierConfigHash,
|
||||||
|
thisFilePath: cacheFilePath,
|
||||||
|
uiModuleMetas
|
||||||
|
});
|
||||||
|
|
||||||
|
const cacheContent_new = Buffer.from(
|
||||||
|
JSON.stringify(parsedCacheFile, null, 2),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cacheContent !== undefined && cacheContent_new.equals(cacheContent)) {
|
||||||
|
break update_cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
create_dir: {
|
||||||
|
const dirPath = pathDirname(cacheFilePath);
|
||||||
|
|
||||||
|
if (await existsAsync(dirPath)) {
|
||||||
|
break create_dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsPr.mkdir(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsPr.writeFile(cacheFilePath, cacheContent_new);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uiModuleMetas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeHash(data: Buffer) {
|
||||||
|
return crypto.createHash("sha256").update(data).digest("hex");
|
||||||
|
}
|
@ -7,7 +7,6 @@ import {
|
|||||||
dirname as pathDirname
|
dirname as pathDirname
|
||||||
} from "path";
|
} from "path";
|
||||||
import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath";
|
import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath";
|
||||||
import type { CliCommandOptions } from "../main";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { assert, type Equals } from "tsafe/assert";
|
import { assert, type Equals } from "tsafe/assert";
|
||||||
@ -19,12 +18,11 @@ import {
|
|||||||
import type { KeycloakVersionRange } from "./KeycloakVersionRange";
|
import type { KeycloakVersionRange } from "./KeycloakVersionRange";
|
||||||
import { exclude } from "tsafe";
|
import { exclude } from "tsafe";
|
||||||
import { crawl } from "../tools/crawl";
|
import { crawl } from "../tools/crawl";
|
||||||
import { THEME_TYPES } from "./constants";
|
import { THEME_TYPES, KEYCLOAK_THEME, type ThemeType } from "./constants";
|
||||||
import { objectEntries } from "tsafe/objectEntries";
|
import { objectEntries } from "tsafe/objectEntries";
|
||||||
import { type ThemeType } from "./constants";
|
|
||||||
import { id } from "tsafe/id";
|
import { id } from "tsafe/id";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { getProxyFetchOptions, type ProxyFetchOptions } from "../tools/fetchProxyOptions";
|
import { getProxyFetchOptions, type FetchOptionsLike } from "../tools/fetchProxyOptions";
|
||||||
import { is } from "tsafe/is";
|
import { is } from "tsafe/is";
|
||||||
|
|
||||||
export type BuildContext = {
|
export type BuildContext = {
|
||||||
@ -43,7 +41,7 @@ export type BuildContext = {
|
|||||||
* In this case the urlPathname will be "/my-app/" */
|
* In this case the urlPathname will be "/my-app/" */
|
||||||
urlPathname: string | undefined;
|
urlPathname: string | undefined;
|
||||||
assetsDirPath: string;
|
assetsDirPath: string;
|
||||||
fetchOptions: ProxyFetchOptions;
|
fetchOptions: FetchOptionsLike;
|
||||||
kcContextExclusionsFtlCode: string | undefined;
|
kcContextExclusionsFtlCode: string | undefined;
|
||||||
environmentVariables: { name: string; default: string }[];
|
environmentVariables: { name: string; default: string }[];
|
||||||
themeSrcDirPath: string;
|
themeSrcDirPath: string;
|
||||||
@ -53,6 +51,7 @@ export type BuildContext = {
|
|||||||
account:
|
account:
|
||||||
| { isImplemented: false }
|
| { isImplemented: false }
|
||||||
| { isImplemented: true; type: "Single-Page" | "Multi-Page" };
|
| { isImplemented: true; type: "Single-Page" | "Multi-Page" };
|
||||||
|
admin: { isImplemented: boolean };
|
||||||
};
|
};
|
||||||
packageJsonFilePath: string;
|
packageJsonFilePath: string;
|
||||||
bundler: "vite" | "webpack";
|
bundler: "vite" | "webpack";
|
||||||
@ -129,14 +128,12 @@ export type ResolvedViteConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function getBuildContext(params: {
|
export function getBuildContext(params: {
|
||||||
cliCommandOptions: CliCommandOptions;
|
projectDirPath: string | undefined;
|
||||||
}): BuildContext {
|
}): BuildContext {
|
||||||
const { cliCommandOptions } = params;
|
|
||||||
|
|
||||||
const projectDirPath =
|
const projectDirPath =
|
||||||
cliCommandOptions.projectDirPath !== undefined
|
params.projectDirPath !== undefined
|
||||||
? getAbsoluteAndInOsFormatPath({
|
? getAbsoluteAndInOsFormatPath({
|
||||||
pathIsh: cliCommandOptions.projectDirPath,
|
pathIsh: params.projectDirPath,
|
||||||
cwd: process.cwd()
|
cwd: process.cwd()
|
||||||
})
|
})
|
||||||
: process.cwd();
|
: process.cwd();
|
||||||
@ -149,7 +146,10 @@ export function getBuildContext(params: {
|
|||||||
returnedPathsType: "relative to dirPath"
|
returnedPathsType: "relative to dirPath"
|
||||||
})
|
})
|
||||||
.map(fileRelativePath => {
|
.map(fileRelativePath => {
|
||||||
for (const themeSrcDirBasename of ["keycloak-theme", "keycloak_theme"]) {
|
for (const themeSrcDirBasename of [
|
||||||
|
KEYCLOAK_THEME,
|
||||||
|
KEYCLOAK_THEME.replace(/-/g, "_")
|
||||||
|
]) {
|
||||||
const split = fileRelativePath.split(themeSrcDirBasename);
|
const split = fileRelativePath.split(themeSrcDirBasename);
|
||||||
if (split.length === 2) {
|
if (split.length === 2) {
|
||||||
return pathJoin(srcDirPath, split[0] + themeSrcDirBasename);
|
return pathJoin(srcDirPath, split[0] + themeSrcDirBasename);
|
||||||
@ -175,7 +175,7 @@ export function getBuildContext(params: {
|
|||||||
[
|
[
|
||||||
`Can't locate your Keycloak theme source directory in .${pathSep}${pathRelative(process.cwd(), srcDirPath)}`,
|
`Can't locate your Keycloak theme source directory in .${pathSep}${pathRelative(process.cwd(), srcDirPath)}`,
|
||||||
`Make sure to either use the Keycloakify CLI in the root of your Keycloakify project or use the --project CLI option`,
|
`Make sure to either use the Keycloakify CLI in the root of your Keycloakify project or use the --project CLI option`,
|
||||||
`If you are collocating your Keycloak theme with your app you must have a directory named 'keycloak-theme' or 'keycloak_theme' in your 'src' directory`
|
`If you are collocating your Keycloak theme with your app you must have a directory named '${KEYCLOAK_THEME}' or '${KEYCLOAK_THEME.replace(/-/g, "_")}' in your 'src' directory`
|
||||||
].join("\n")
|
].join("\n")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -241,8 +241,7 @@ export function getBuildContext(params: {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
parsedPackageJson.dependencies?.keycloakify === undefined &&
|
parsedPackageJson.dependencies?.keycloakify === undefined &&
|
||||||
parsedPackageJson.devDependencies?.keycloakify === undefined &&
|
parsedPackageJson.devDependencies?.keycloakify === undefined
|
||||||
parsedPackageJson.name !== "keycloakify" // NOTE: For local storybook build
|
|
||||||
) {
|
) {
|
||||||
break success;
|
break success;
|
||||||
}
|
}
|
||||||
@ -452,7 +451,10 @@ export function getBuildContext(params: {
|
|||||||
isImplemented: true,
|
isImplemented: true,
|
||||||
type: buildOptions.accountThemeImplementation
|
type: buildOptions.accountThemeImplementation
|
||||||
};
|
};
|
||||||
})()
|
})(),
|
||||||
|
admin: {
|
||||||
|
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "admin"))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -471,6 +473,7 @@ export function getBuildContext(params: {
|
|||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const themeNames = ((): [string, ...string[]] => {
|
||||||
const themeNames = ((): [string, ...string[]] => {
|
const themeNames = ((): [string, ...string[]] => {
|
||||||
if (buildOptions.themeName === undefined) {
|
if (buildOptions.themeName === undefined) {
|
||||||
return parsedPackageJson.name === undefined
|
return parsedPackageJson.name === undefined
|
||||||
@ -494,6 +497,32 @@ export function getBuildContext(params: {
|
|||||||
return [mainThemeName, ...themeVariantNames];
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return themeNames;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const relativePathsCwd = (() => {
|
||||||
|
switch (bundler) {
|
||||||
|
case "vite":
|
||||||
|
return projectDirPath;
|
||||||
|
case "webpack":
|
||||||
|
return pathDirname(packageJsonFilePath);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const projectBuildDirPath = (() => {
|
const projectBuildDirPath = (() => {
|
||||||
webpack: {
|
webpack: {
|
||||||
if (bundler !== "webpack") {
|
if (bundler !== "webpack") {
|
||||||
@ -505,7 +534,7 @@ export function getBuildContext(params: {
|
|||||||
if (parsedPackageJson.keycloakify.projectBuildDirPath !== undefined) {
|
if (parsedPackageJson.keycloakify.projectBuildDirPath !== undefined) {
|
||||||
return getAbsoluteAndInOsFormatPath({
|
return getAbsoluteAndInOsFormatPath({
|
||||||
pathIsh: parsedPackageJson.keycloakify.projectBuildDirPath,
|
pathIsh: parsedPackageJson.keycloakify.projectBuildDirPath,
|
||||||
cwd: projectDirPath
|
cwd: relativePathsCwd
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -549,7 +578,7 @@ export function getBuildContext(params: {
|
|||||||
if (buildOptions.keycloakifyBuildDirPath !== undefined) {
|
if (buildOptions.keycloakifyBuildDirPath !== undefined) {
|
||||||
return getAbsoluteAndInOsFormatPath({
|
return getAbsoluteAndInOsFormatPath({
|
||||||
pathIsh: buildOptions.keycloakifyBuildDirPath,
|
pathIsh: buildOptions.keycloakifyBuildDirPath,
|
||||||
cwd: projectDirPath
|
cwd: relativePathsCwd
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -578,7 +607,7 @@ export function getBuildContext(params: {
|
|||||||
if (parsedPackageJson.keycloakify.publicDirPath !== undefined) {
|
if (parsedPackageJson.keycloakify.publicDirPath !== undefined) {
|
||||||
return getAbsoluteAndInOsFormatPath({
|
return getAbsoluteAndInOsFormatPath({
|
||||||
pathIsh: parsedPackageJson.keycloakify.publicDirPath,
|
pathIsh: parsedPackageJson.keycloakify.publicDirPath,
|
||||||
cwd: projectDirPath
|
cwd: relativePathsCwd
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -650,7 +679,7 @@ export function getBuildContext(params: {
|
|||||||
pathIsh:
|
pathIsh:
|
||||||
parsedPackageJson.keycloakify
|
parsedPackageJson.keycloakify
|
||||||
.staticDirPathInProjectBuildDirPath,
|
.staticDirPathInProjectBuildDirPath,
|
||||||
cwd: projectBuildDirPath
|
cwd: relativePathsCwd
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -978,7 +1007,7 @@ export function getBuildContext(params: {
|
|||||||
type: "path",
|
type: "path",
|
||||||
path: getAbsoluteAndInOsFormatPath({
|
path: getAbsoluteAndInOsFormatPath({
|
||||||
pathIsh: urlOrPath,
|
pathIsh: urlOrPath,
|
||||||
cwd: projectDirPath
|
cwd: relativePathsCwd
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -988,7 +1017,7 @@ export function getBuildContext(params: {
|
|||||||
? undefined
|
? undefined
|
||||||
: getAbsoluteAndInOsFormatPath({
|
: getAbsoluteAndInOsFormatPath({
|
||||||
pathIsh: buildOptions.startKeycloakOptions.realmJsonFilePath,
|
pathIsh: buildOptions.startKeycloakOptions.realmJsonFilePath,
|
||||||
cwd: projectDirPath
|
cwd: relativePathsCwd
|
||||||
}),
|
}),
|
||||||
port: buildOptions.startKeycloakOptions?.port
|
port: buildOptions.startKeycloakOptions?.port
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ export const WELL_KNOWN_DIRECTORY_BASE_NAME = {
|
|||||||
DIST: "dist"
|
DIST: "dist"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const THEME_TYPES = ["login", "account"] as const;
|
export const THEME_TYPES = ["login", "account", "admin"] as const;
|
||||||
|
|
||||||
export type ThemeType = (typeof THEME_TYPES)[number];
|
export type ThemeType = (typeof THEME_TYPES)[number];
|
||||||
|
|
||||||
@ -71,3 +71,10 @@ export type AccountThemePageId = (typeof ACCOUNT_THEME_PAGE_IDS)[number];
|
|||||||
export const CONTAINER_NAME = "keycloak-keycloakify";
|
export const CONTAINER_NAME = "keycloak-keycloakify";
|
||||||
|
|
||||||
export const FALLBACK_LANGUAGE_TAG = "en";
|
export const FALLBACK_LANGUAGE_TAG = "en";
|
||||||
|
|
||||||
|
export const CUSTOM_HANDLER_ENV_NAMES = {
|
||||||
|
COMMAND_NAME: "KEYCLOAKIFY_COMMAND_NAME",
|
||||||
|
BUILD_CONTEXT: "KEYCLOAKIFY_BUILD_CONTEXT"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KEYCLOAK_THEME = "keycloak-theme";
|
||||||
|
@ -1,95 +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.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")
|
|
||||||
);
|
|
||||||
}
|
|
42
src/bin/shared/customHandler.ts
Normal file
42
src/bin/shared/customHandler.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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-admin-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 };
|
||||||
|
}
|
48
src/bin/shared/customHandler_delegate.ts
Normal file
48
src/bin/shared/customHandler_delegate.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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 { getNodeModulesBinDirPath } from "../tools/nodeModulesBinDirPath";
|
||||||
|
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 = getNodeModulesBinDirPath();
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
36
src/bin/shared/exitIfUncommittedChanges.ts
Normal file
36
src/bin/shared/exitIfUncommittedChanges.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import child_process from "child_process";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
export function exitIfUncommittedChanges(params: { projectDirPath: string }) {
|
||||||
|
const { projectDirPath } = params;
|
||||||
|
|
||||||
|
let hasUncommittedChanges: boolean | undefined = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
hasUncommittedChanges =
|
||||||
|
child_process
|
||||||
|
.execSync(`git status --porcelain`, {
|
||||||
|
cwd: projectDirPath
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim() !== "";
|
||||||
|
} catch {
|
||||||
|
// Probably not a git repository
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUncommittedChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
[
|
||||||
|
chalk.red(
|
||||||
|
"Please commit or stash your changes before running this command.\n"
|
||||||
|
),
|
||||||
|
"This command will modify your project's files so it's better to have a clean working directory",
|
||||||
|
"so that you can easily see what has been changed and revert if needed."
|
||||||
|
].join(" ")
|
||||||
|
);
|
||||||
|
|
||||||
|
process.exit(-1);
|
||||||
|
}
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
2400
src/bin/start-keycloak/myrealm-realm-26.json
Normal file
2400
src/bin/start-keycloak/myrealm-realm-26.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,5 @@
|
|||||||
import { getBuildContext } from "../shared/buildContext";
|
import type { BuildContext } from "../shared/buildContext";
|
||||||
import { exclude } from "tsafe/exclude";
|
import { exclude } from "tsafe/exclude";
|
||||||
import type { CliCommandOptions as CliCommandOptions_common } from "../main";
|
|
||||||
import { promptKeycloakVersion } from "../shared/promptKeycloakVersion";
|
import { promptKeycloakVersion } from "../shared/promptKeycloakVersion";
|
||||||
import { CONTAINER_NAME } from "../shared/constants";
|
import { CONTAINER_NAME } from "../shared/constants";
|
||||||
import { SemVer } from "../tools/SemVer";
|
import { SemVer } from "../tools/SemVer";
|
||||||
@ -29,13 +28,14 @@ import { existsAsync } from "../tools/fs.existsAsync";
|
|||||||
import { rm } from "../tools/fs.rm";
|
import { rm } from "../tools/fs.rm";
|
||||||
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
|
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
|
||||||
|
|
||||||
export type CliCommandOptions = CliCommandOptions_common & {
|
export async function command(params: {
|
||||||
|
buildContext: BuildContext;
|
||||||
|
cliCommandOptions: {
|
||||||
port: number | undefined;
|
port: number | undefined;
|
||||||
keycloakVersion: string | undefined;
|
keycloakVersion: string | undefined;
|
||||||
realmJsonFilePath: string | undefined;
|
realmJsonFilePath: string | undefined;
|
||||||
};
|
};
|
||||||
|
}) {
|
||||||
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
|
|
||||||
exit_if_docker_not_installed: {
|
exit_if_docker_not_installed: {
|
||||||
let commandOutput: string | undefined = undefined;
|
let commandOutput: string | undefined = undefined;
|
||||||
|
|
||||||
@ -88,9 +88,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { cliCommandOptions } = params;
|
const { cliCommandOptions, buildContext } = params;
|
||||||
|
|
||||||
const buildContext = getBuildContext({ cliCommandOptions });
|
|
||||||
|
|
||||||
const { dockerImageTag } = await (async () => {
|
const { dockerImageTag } = await (async () => {
|
||||||
if (cliCommandOptions.keycloakVersion !== undefined) {
|
if (cliCommandOptions.keycloakVersion !== undefined) {
|
||||||
@ -398,12 +396,12 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
...(realmJsonFilePath === undefined
|
...(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(
|
...extensionJarFilePaths.map(
|
||||||
jarFilePath =>
|
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
|
...(keycloakMajorVersionNumber <= 20
|
||||||
? [`-e${SPACE_PLACEHOLDER}JAVA_OPTS=-Dkeycloak.profile=preview`]
|
? [`-e${SPACE_PLACEHOLDER}JAVA_OPTS=-Dkeycloak.profile=preview`]
|
||||||
@ -426,7 +424,7 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
|
|||||||
}))
|
}))
|
||||||
.map(
|
.map(
|
||||||
({ localDirPath, containerDirPath }) =>
|
({ localDirPath, containerDirPath }) =>
|
||||||
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), localDirPath)}":${containerDirPath}:rw`
|
`-v${SPACE_PLACEHOLDER}"${localDirPath}":${containerDirPath}:rw`
|
||||||
),
|
),
|
||||||
...buildContext.environmentVariables
|
...buildContext.environmentVariables
|
||||||
.map(({ name }) => ({ name, envValue: process.env[name] }))
|
.map(({ name }) => ({ name, envValue: process.env[name] }))
|
||||||
|
51
src/bin/tools/crawlAsync.ts
Normal file
51
src/bin/tools/crawlAsync.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import * as fsPr from "fs/promises";
|
||||||
|
import { join as pathJoin, relative as pathRelative } from "path";
|
||||||
|
import { assert, type Equals } from "tsafe/assert";
|
||||||
|
|
||||||
|
/** List all files in a given directory return paths relative to the dir_path */
|
||||||
|
export async function crawlAsync(params: {
|
||||||
|
dirPath: string;
|
||||||
|
returnedPathsType: "absolute" | "relative to dirPath";
|
||||||
|
onFileFound: (filePath: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const { dirPath, returnedPathsType, onFileFound } = params;
|
||||||
|
|
||||||
|
await crawlAsyncRec({
|
||||||
|
dirPath,
|
||||||
|
onFileFound: async ({ filePath }) => {
|
||||||
|
switch (returnedPathsType) {
|
||||||
|
case "absolute":
|
||||||
|
await onFileFound(filePath);
|
||||||
|
return;
|
||||||
|
case "relative to dirPath":
|
||||||
|
await onFileFound(pathRelative(dirPath, filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert<Equals<typeof returnedPathsType, never>>();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function crawlAsyncRec(params: {
|
||||||
|
dirPath: string;
|
||||||
|
onFileFound: (params: { filePath: string }) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const { dirPath, onFileFound } = params;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
(await fsPr.readdir(dirPath)).map(async basename => {
|
||||||
|
const fileOrDirPath = pathJoin(dirPath, basename);
|
||||||
|
|
||||||
|
const isDirectory = await fsPr
|
||||||
|
.lstat(fileOrDirPath)
|
||||||
|
.then(stat => stat.isDirectory());
|
||||||
|
|
||||||
|
if (isDirectory) {
|
||||||
|
await crawlAsyncRec({ dirPath: fileOrDirPath, onFileFound });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onFileFound({ filePath: fileOrDirPath });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
@ -1,16 +1,18 @@
|
|||||||
import { type FetchOptions } from "make-fetch-happen";
|
|
||||||
import * as child_process from "child_process";
|
import * as child_process from "child_process";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { exclude } from "tsafe/exclude";
|
import { exclude } from "tsafe/exclude";
|
||||||
|
|
||||||
export type ProxyFetchOptions = Pick<
|
export type FetchOptionsLike = {
|
||||||
FetchOptions,
|
proxy: string | undefined;
|
||||||
"proxy" | "noProxy" | "strictSSL" | "cert" | "ca"
|
noProxy: string | string[];
|
||||||
>;
|
strictSSL: boolean;
|
||||||
|
cert: string | string[] | undefined;
|
||||||
|
ca: string[] | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export function getProxyFetchOptions(params: {
|
export function getProxyFetchOptions(params: {
|
||||||
npmConfigGetCwd: string;
|
npmConfigGetCwd: string;
|
||||||
}): ProxyFetchOptions {
|
}): FetchOptionsLike {
|
||||||
const { npmConfigGetCwd } = params;
|
const { npmConfigGetCwd } = params;
|
||||||
|
|
||||||
const cfg = (() => {
|
const cfg = (() => {
|
||||||
|
51
src/bin/tools/getInstalledModuleDirPath.ts
Normal file
51
src/bin/tools/getInstalledModuleDirPath.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { join as pathJoin } from "path";
|
||||||
|
import { existsAsync } from "./fs.existsAsync";
|
||||||
|
import * as child_process from "child_process";
|
||||||
|
import { assert } from "tsafe/assert";
|
||||||
|
|
||||||
|
export async function getInstalledModuleDirPath(params: {
|
||||||
|
moduleName: string;
|
||||||
|
packageJsonDirPath: string;
|
||||||
|
projectDirPath: string;
|
||||||
|
}) {
|
||||||
|
const { moduleName, packageJsonDirPath, projectDirPath } = params;
|
||||||
|
|
||||||
|
common_case: {
|
||||||
|
const dirPath = pathJoin(
|
||||||
|
...[packageJsonDirPath, "node_modules", ...moduleName.split("/")]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(await existsAsync(dirPath))) {
|
||||||
|
break common_case;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
node_modules_at_root_case: {
|
||||||
|
if (projectDirPath === packageJsonDirPath) {
|
||||||
|
break node_modules_at_root_case;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirPath = pathJoin(
|
||||||
|
...[projectDirPath, "node_modules", ...moduleName.split("/")]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(await existsAsync(dirPath))) {
|
||||||
|
break node_modules_at_root_case;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirPath = child_process
|
||||||
|
.execSync(`npm list ${moduleName}`, {
|
||||||
|
cwd: packageJsonDirPath
|
||||||
|
})
|
||||||
|
.toString("utf8")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
assert(dirPath !== "");
|
||||||
|
|
||||||
|
return dirPath;
|
||||||
|
}
|
29
src/bin/tools/isTrackedByGit.ts
Normal file
29
src/bin/tools/isTrackedByGit.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import * as child_process from "child_process";
|
||||||
|
import { dirname as pathDirname, basename as pathBasename } from "path";
|
||||||
|
import { Deferred } from "evt/tools/Deferred";
|
||||||
|
|
||||||
|
export function getIsTrackedByGit(params: { filePath: string }): Promise<boolean> {
|
||||||
|
const { filePath } = params;
|
||||||
|
|
||||||
|
const dIsTracked = new Deferred<boolean>();
|
||||||
|
|
||||||
|
child_process.exec(
|
||||||
|
`git ls-files --error-unmatch ${pathBasename(filePath)}`,
|
||||||
|
{ cwd: pathDirname(filePath) },
|
||||||
|
error => {
|
||||||
|
if (error === null) {
|
||||||
|
dIsTracked.resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 1) {
|
||||||
|
dIsTracked.resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dIsTracked.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return dIsTracked.pr;
|
||||||
|
}
|
131
src/bin/tools/listInstalledModules.ts
Normal file
131
src/bin/tools/listInstalledModules.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { assert, type Equals } from "tsafe/assert";
|
||||||
|
import { id } from "tsafe/id";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { join as pathJoin, dirname as pathDirname } from "path";
|
||||||
|
import * as fsPr from "fs/promises";
|
||||||
|
import { is } from "tsafe/is";
|
||||||
|
import { getInstalledModuleDirPath } from "../tools/getInstalledModuleDirPath";
|
||||||
|
import { exclude } from "tsafe/exclude";
|
||||||
|
|
||||||
|
export async function listInstalledModules(params: {
|
||||||
|
packageJsonFilePath: string;
|
||||||
|
projectDirPath: string;
|
||||||
|
filter: (params: { moduleName: string }) => boolean;
|
||||||
|
}): Promise<
|
||||||
|
{
|
||||||
|
moduleName: string;
|
||||||
|
version: string;
|
||||||
|
dirPath: string;
|
||||||
|
peerDependencies: Record<string, string>;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
const { packageJsonFilePath, projectDirPath, filter } = params;
|
||||||
|
|
||||||
|
const parsedPackageJson = await readPackageJsonDependencies({
|
||||||
|
packageJsonFilePath
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiModuleNames = (
|
||||||
|
[parsedPackageJson.dependencies, parsedPackageJson.devDependencies] as const
|
||||||
|
)
|
||||||
|
.filter(exclude(undefined))
|
||||||
|
.map(obj => Object.keys(obj))
|
||||||
|
.flat()
|
||||||
|
.filter(moduleName => filter({ moduleName }));
|
||||||
|
|
||||||
|
const result = await Promise.all(
|
||||||
|
uiModuleNames.map(async moduleName => {
|
||||||
|
const dirPath = await getInstalledModuleDirPath({
|
||||||
|
moduleName,
|
||||||
|
packageJsonDirPath: pathDirname(packageJsonFilePath),
|
||||||
|
projectDirPath
|
||||||
|
});
|
||||||
|
|
||||||
|
const { version, peerDependencies } =
|
||||||
|
await readPackageJsonVersionAndPeerDependencies({
|
||||||
|
packageJsonFilePath: pathJoin(dirPath, "package.json")
|
||||||
|
});
|
||||||
|
|
||||||
|
return { moduleName, version, peerDependencies, dirPath } as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { readPackageJsonDependencies } = (() => {
|
||||||
|
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);
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function readPackageJsonDependencies(params: { packageJsonFilePath: string }) {
|
||||||
|
const { packageJsonFilePath } = params;
|
||||||
|
|
||||||
|
const parsedPackageJson = JSON.parse(
|
||||||
|
(await fsPr.readFile(packageJsonFilePath)).toString("utf8")
|
||||||
|
);
|
||||||
|
|
||||||
|
zParsedPackageJson.parse(parsedPackageJson);
|
||||||
|
|
||||||
|
assert(is<ParsedPackageJson>(parsedPackageJson));
|
||||||
|
|
||||||
|
return parsedPackageJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { readPackageJsonDependencies };
|
||||||
|
})();
|
||||||
|
|
||||||
|
const { readPackageJsonVersionAndPeerDependencies } = (() => {
|
||||||
|
type ParsedPackageJson = {
|
||||||
|
version: string;
|
||||||
|
peerDependencies?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zParsedPackageJson = (() => {
|
||||||
|
type TargetType = ParsedPackageJson;
|
||||||
|
|
||||||
|
const zTargetType = z.object({
|
||||||
|
version: z.string(),
|
||||||
|
peerDependencies: z.record(z.string()).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
|
||||||
|
|
||||||
|
return id<z.ZodType<TargetType>>(zTargetType);
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function readPackageJsonVersionAndPeerDependencies(params: {
|
||||||
|
packageJsonFilePath: string;
|
||||||
|
}): Promise<{ version: string; peerDependencies: Record<string, string> }> {
|
||||||
|
const { packageJsonFilePath } = params;
|
||||||
|
|
||||||
|
const parsedPackageJson = JSON.parse(
|
||||||
|
(await fsPr.readFile(packageJsonFilePath)).toString("utf8")
|
||||||
|
);
|
||||||
|
|
||||||
|
zParsedPackageJson.parse(parsedPackageJson);
|
||||||
|
|
||||||
|
assert(is<ParsedPackageJson>(parsedPackageJson));
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: parsedPackageJson.version,
|
||||||
|
peerDependencies: parsedPackageJson.peerDependencies ?? {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { readPackageJsonVersionAndPeerDependencies };
|
||||||
|
})();
|
38
src/bin/tools/nodeModulesBinDirPath.ts
Normal file
38
src/bin/tools/nodeModulesBinDirPath.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { sep as pathSep } from "path";
|
||||||
|
|
||||||
|
let cache: string | undefined = undefined;
|
||||||
|
|
||||||
|
export function getNodeModulesBinDirPath() {
|
||||||
|
if (cache !== undefined) {
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeModulesBinDirPath = segments.join(pathSep);
|
||||||
|
|
||||||
|
cache = nodeModulesBinDirPath;
|
||||||
|
|
||||||
|
return nodeModulesBinDirPath;
|
||||||
|
}
|
@ -1,7 +1,15 @@
|
|||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { join as pathJoin } from "path";
|
import { join as pathJoin, dirname as pathDirname } from "path";
|
||||||
import * as child_process from "child_process";
|
import * as child_process from "child_process";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { assert, type Equals } from "tsafe/assert";
|
||||||
|
import { id } from "tsafe/id";
|
||||||
|
import { is } from "tsafe/is";
|
||||||
|
import { objectKeys } from "tsafe/objectKeys";
|
||||||
|
import { getAbsoluteAndInOsFormatPath } from "./getAbsoluteAndInOsFormatPath";
|
||||||
|
import { exclude } from "tsafe/exclude";
|
||||||
|
import { rmSync } from "./fs.rmSync";
|
||||||
|
|
||||||
export function npmInstall(params: { packageJsonDirPath: string }) {
|
export function npmInstall(params: { packageJsonDirPath: string }) {
|
||||||
const { packageJsonDirPath } = params;
|
const { packageJsonDirPath } = params;
|
||||||
@ -23,6 +31,10 @@ export function npmInstall(params: { packageJsonDirPath: string }) {
|
|||||||
{
|
{
|
||||||
binName: "bun",
|
binName: "bun",
|
||||||
lockFileBasename: "bun.lockdb"
|
lockFileBasename: "bun.lockdb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binName: "deno",
|
||||||
|
lockFileBasename: "deno.lock"
|
||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@ -37,15 +49,33 @@ export function npmInstall(params: { packageJsonDirPath: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
throw new Error(
|
||||||
|
"No lock file found, cannot tell which package manager to use for installing dependencies."
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
install_dependencies: {
|
console.log(`Installing the new dependencies...`);
|
||||||
if (packageManagerBinName === undefined) {
|
|
||||||
break install_dependencies;
|
install_without_breaking_links: {
|
||||||
|
if (packageManagerBinName !== "yarn") {
|
||||||
|
break install_without_breaking_links;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Installing the new dependencies...`);
|
const garronejLinkInfos = getGarronejLinkInfos({ packageJsonDirPath });
|
||||||
|
|
||||||
|
if (garronejLinkInfos === undefined) {
|
||||||
|
break install_without_breaking_links;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.green("Installing in a way that won't break the links..."));
|
||||||
|
|
||||||
|
installWithoutBreakingLinks({
|
||||||
|
packageJsonDirPath,
|
||||||
|
garronejLinkInfos
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
child_process.execSync(`${packageManagerBinName} install`, {
|
child_process.execSync(`${packageManagerBinName} install`, {
|
||||||
@ -60,4 +90,370 @@ export function npmInstall(params: { packageJsonDirPath: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGarronejLinkInfos(params: {
|
||||||
|
packageJsonDirPath: string;
|
||||||
|
}): { linkedModuleNames: string[]; yarnHomeDirPath: string } | undefined {
|
||||||
|
const { packageJsonDirPath } = params;
|
||||||
|
|
||||||
|
const nodeModuleDirPath = pathJoin(packageJsonDirPath, "node_modules");
|
||||||
|
|
||||||
|
if (!fs.existsSync(nodeModuleDirPath)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedModuleNames: string[] = [];
|
||||||
|
|
||||||
|
let yarnHomeDirPath: string | undefined = undefined;
|
||||||
|
|
||||||
|
const getIsLinkedByGarronejScript = (path: string) => {
|
||||||
|
let realPath: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
realPath = fs.readlinkSync(path);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doesIncludeYarnHome = realPath.includes(".yarn_home");
|
||||||
|
|
||||||
|
if (!doesIncludeYarnHome) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_yarnHomeDirPath: {
|
||||||
|
if (yarnHomeDirPath !== undefined) {
|
||||||
|
break set_yarnHomeDirPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [firstElement] = getAbsoluteAndInOsFormatPath({
|
||||||
|
pathIsh: realPath,
|
||||||
|
cwd: pathDirname(path)
|
||||||
|
}).split(".yarn_home");
|
||||||
|
|
||||||
|
yarnHomeDirPath = pathJoin(firstElement, ".yarn_home");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const basename of fs.readdirSync(nodeModuleDirPath)) {
|
||||||
|
const path = pathJoin(nodeModuleDirPath, basename);
|
||||||
|
|
||||||
|
if (fs.lstatSync(path).isSymbolicLink()) {
|
||||||
|
if (basename.startsWith("@")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getIsLinkedByGarronejScript(path)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
linkedModuleNames.push(basename);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.lstatSync(path).isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basename.startsWith("@")) {
|
||||||
|
for (const subBasename of fs.readdirSync(path)) {
|
||||||
|
const subPath = pathJoin(path, subBasename);
|
||||||
|
|
||||||
|
if (!fs.lstatSync(subPath).isSymbolicLink()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getIsLinkedByGarronejScript(subPath)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
linkedModuleNames.push(`${basename}/${subBasename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yarnHomeDirPath === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { linkedModuleNames, yarnHomeDirPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function installWithoutBreakingLinks(params: {
|
||||||
|
packageJsonDirPath: string;
|
||||||
|
garronejLinkInfos: Exclude<ReturnType<typeof getGarronejLinkInfos>, undefined>;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
packageJsonDirPath,
|
||||||
|
garronejLinkInfos: { linkedModuleNames, yarnHomeDirPath }
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const parsedPackageJson = (() => {
|
||||||
|
const packageJsonFilePath = pathJoin(packageJsonDirPath, "package.json");
|
||||||
|
|
||||||
|
type ParsedPackageJson = {
|
||||||
|
scripts?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zParsedPackageJson = (() => {
|
||||||
|
type TargetType = ParsedPackageJson;
|
||||||
|
|
||||||
|
const zTargetType = z.object({
|
||||||
|
scripts: z.record(z.string()).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
type InferredType = z.infer<typeof zTargetType>;
|
||||||
|
|
||||||
|
assert<Equals<TargetType, InferredType>>;
|
||||||
|
|
||||||
|
return id<z.ZodType<TargetType>>(zTargetType);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const parsedPackageJson = JSON.parse(
|
||||||
|
fs.readFileSync(packageJsonFilePath).toString("utf8")
|
||||||
|
) as unknown;
|
||||||
|
|
||||||
|
zParsedPackageJson.parse(parsedPackageJson);
|
||||||
|
assert(is<ParsedPackageJson>(parsedPackageJson));
|
||||||
|
|
||||||
|
return parsedPackageJson;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const isImplementedScriptByName = {
|
||||||
|
postinstall: false,
|
||||||
|
prepare: false
|
||||||
|
};
|
||||||
|
|
||||||
|
delete_postinstall_script: {
|
||||||
|
if (parsedPackageJson.scripts === undefined) {
|
||||||
|
break delete_postinstall_script;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scriptName of objectKeys(isImplementedScriptByName)) {
|
||||||
|
if (parsedPackageJson.scripts[scriptName] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
isImplementedScriptByName[scriptName] = true;
|
||||||
|
|
||||||
|
delete parsedPackageJson.scripts[scriptName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpProjectDirPath = pathJoin(yarnHomeDirPath, "tmpProject");
|
||||||
|
|
||||||
|
if (fs.existsSync(tmpProjectDirPath)) {
|
||||||
|
rmSync(tmpProjectDirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(tmpProjectDirPath, { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
pathJoin(tmpProjectDirPath, "package.json"),
|
||||||
|
JSON.stringify(parsedPackageJson, undefined, 4)
|
||||||
|
);
|
||||||
|
|
||||||
|
const YARN_LOCK = "yarn.lock";
|
||||||
|
|
||||||
|
fs.copyFileSync(
|
||||||
|
pathJoin(packageJsonDirPath, YARN_LOCK),
|
||||||
|
pathJoin(tmpProjectDirPath, YARN_LOCK)
|
||||||
|
);
|
||||||
|
|
||||||
|
child_process.execSync(`yarn install`, {
|
||||||
|
cwd: tmpProjectDirPath,
|
||||||
|
stdio: "inherit"
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: Moving the modules from the tmp project to the actual project
|
||||||
|
// without messing up the links.
|
||||||
|
{
|
||||||
|
const { getAreSameVersions } = (() => {
|
||||||
|
type ParsedPackageJson = {
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zParsedPackageJson = (() => {
|
||||||
|
type TargetType = ParsedPackageJson;
|
||||||
|
|
||||||
|
const zTargetType = z.object({
|
||||||
|
version: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
type InferredType = z.infer<typeof zTargetType>;
|
||||||
|
|
||||||
|
assert<Equals<TargetType, InferredType>>;
|
||||||
|
|
||||||
|
return id<z.ZodType<TargetType>>(zTargetType);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function readVersion(params: { moduleDirPath: string }): string {
|
||||||
|
const { moduleDirPath } = params;
|
||||||
|
|
||||||
|
const packageJsonFilePath = pathJoin(moduleDirPath, "package.json");
|
||||||
|
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
fs.readFileSync(packageJsonFilePath).toString("utf8")
|
||||||
|
);
|
||||||
|
|
||||||
|
zParsedPackageJson.parse(packageJson);
|
||||||
|
assert(is<ParsedPackageJson>(packageJson));
|
||||||
|
|
||||||
|
return packageJson.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAreSameVersions(params: {
|
||||||
|
moduleDirPath_a: string;
|
||||||
|
moduleDirPath_b: string;
|
||||||
|
}): boolean {
|
||||||
|
const { moduleDirPath_a, moduleDirPath_b } = params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
readVersion({ moduleDirPath: moduleDirPath_a }) ===
|
||||||
|
readVersion({ moduleDirPath: moduleDirPath_b })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAreSameVersions };
|
||||||
|
})();
|
||||||
|
|
||||||
|
const nodeModulesDirPath_tmpProject = pathJoin(tmpProjectDirPath, "node_modules");
|
||||||
|
const nodeModulesDirPath = pathJoin(packageJsonDirPath, "node_modules");
|
||||||
|
|
||||||
|
const modulePaths = fs
|
||||||
|
.readdirSync(nodeModulesDirPath_tmpProject)
|
||||||
|
.map(basename => {
|
||||||
|
if (basename.startsWith(".")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = pathJoin(nodeModulesDirPath_tmpProject, basename);
|
||||||
|
|
||||||
|
if (basename.startsWith("@")) {
|
||||||
|
return fs
|
||||||
|
.readdirSync(path)
|
||||||
|
.map(subBasename => {
|
||||||
|
if (subBasename.startsWith(".")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subPath = pathJoin(path, subBasename);
|
||||||
|
|
||||||
|
if (!fs.lstatSync(subPath).isDirectory()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
moduleName: `${basename}/${subBasename}`,
|
||||||
|
moduleDirPath_tmpProject: subPath,
|
||||||
|
moduleDirPath: pathJoin(
|
||||||
|
nodeModulesDirPath,
|
||||||
|
basename,
|
||||||
|
subBasename
|
||||||
|
)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(exclude(undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.lstatSync(path).isDirectory()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
moduleName: basename,
|
||||||
|
moduleDirPath_tmpProject: path,
|
||||||
|
moduleDirPath: pathJoin(nodeModulesDirPath, basename)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.filter(exclude(undefined))
|
||||||
|
.flat();
|
||||||
|
|
||||||
|
for (const {
|
||||||
|
moduleName,
|
||||||
|
moduleDirPath,
|
||||||
|
moduleDirPath_tmpProject
|
||||||
|
} of modulePaths) {
|
||||||
|
if (linkedModuleNames.includes(moduleName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let doesTargetModuleExist = false;
|
||||||
|
|
||||||
|
skip_condition: {
|
||||||
|
if (!fs.existsSync(moduleDirPath)) {
|
||||||
|
break skip_condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
doesTargetModuleExist = true;
|
||||||
|
|
||||||
|
const areSameVersions = getAreSameVersions({
|
||||||
|
moduleDirPath_a: moduleDirPath,
|
||||||
|
moduleDirPath_b: moduleDirPath_tmpProject
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!areSameVersions) {
|
||||||
|
break skip_condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doesTargetModuleExist) {
|
||||||
|
rmSync(moduleDirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const dirPath = pathDirname(moduleDirPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(moduleDirPath_tmpProject, moduleDirPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
move_bin: {
|
||||||
|
const binDirPath_tmpProject = pathJoin(nodeModulesDirPath_tmpProject, ".bin");
|
||||||
|
const binDirPath = pathJoin(nodeModulesDirPath, ".bin");
|
||||||
|
|
||||||
|
if (!fs.existsSync(binDirPath_tmpProject)) {
|
||||||
|
break move_bin;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const basename of fs.readdirSync(binDirPath_tmpProject)) {
|
||||||
|
const path_tmpProject = pathJoin(binDirPath_tmpProject, basename);
|
||||||
|
const path = pathJoin(binDirPath, basename);
|
||||||
|
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(path_tmpProject, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.cpSync(
|
||||||
|
pathJoin(tmpProjectDirPath, YARN_LOCK),
|
||||||
|
pathJoin(packageJsonDirPath, YARN_LOCK)
|
||||||
|
);
|
||||||
|
|
||||||
|
rmSync(tmpProjectDirPath, { recursive: true });
|
||||||
|
|
||||||
|
for (const scriptName of objectKeys(isImplementedScriptByName)) {
|
||||||
|
if (!isImplementedScriptByName[scriptName]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
child_process.execSync(`yarn run ${scriptName}`, {
|
||||||
|
cwd: packageJsonDirPath,
|
||||||
|
stdio: "inherit"
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,13 @@ import { assert } from "tsafe/assert";
|
|||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { join as pathJoin } from "path";
|
import { join as pathJoin } from "path";
|
||||||
|
|
||||||
|
let cache: string | undefined = undefined;
|
||||||
|
|
||||||
export function readThisNpmPackageVersion(): string {
|
export function readThisNpmPackageVersion(): string {
|
||||||
|
if (cache !== undefined) {
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
const version = JSON.parse(
|
const version = JSON.parse(
|
||||||
fs
|
fs
|
||||||
.readFileSync(pathJoin(getThisCodebaseRootDirPath(), "package.json"))
|
.readFileSync(pathJoin(getThisCodebaseRootDirPath(), "package.json"))
|
||||||
@ -12,5 +18,7 @@ export function readThisNpmPackageVersion(): string {
|
|||||||
|
|
||||||
assert(typeof version === "string");
|
assert(typeof version === "string");
|
||||||
|
|
||||||
|
cache = version;
|
||||||
|
|
||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
127
src/bin/tools/runPrettier.ts
Normal file
127
src/bin/tools/runPrettier.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { getNodeModulesBinDirPath } from "./nodeModulesBinDirPath";
|
||||||
|
import { join as pathJoin, resolve as pathResolve } from "path";
|
||||||
|
import * as fsPr from "fs/promises";
|
||||||
|
import { id } from "tsafe/id";
|
||||||
|
import { assert } from "tsafe/assert";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
import { is } from "tsafe/is";
|
||||||
|
import { symToStr } from "tsafe/symToStr";
|
||||||
|
import { readThisNpmPackageVersion } from "./readThisNpmPackageVersion";
|
||||||
|
|
||||||
|
getIsPrettierAvailable.cache = id<boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
export async function getIsPrettierAvailable(): Promise<boolean> {
|
||||||
|
if (getIsPrettierAvailable.cache !== undefined) {
|
||||||
|
return getIsPrettierAvailable.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeModulesBinDirPath = getNodeModulesBinDirPath();
|
||||||
|
|
||||||
|
const prettierBinPath = pathJoin(nodeModulesBinDirPath, "prettier");
|
||||||
|
|
||||||
|
const stats = await fsPr.stat(prettierBinPath).catch(() => undefined);
|
||||||
|
|
||||||
|
const isPrettierAvailable = stats?.isFile() ?? false;
|
||||||
|
|
||||||
|
getIsPrettierAvailable.cache = isPrettierAvailable;
|
||||||
|
|
||||||
|
return isPrettierAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrettierAndConfigHash = {
|
||||||
|
prettier: typeof import("prettier");
|
||||||
|
configHash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
getPrettier.cache = id<PrettierAndConfigHash | undefined>(undefined);
|
||||||
|
|
||||||
|
export async function getPrettier(): Promise<PrettierAndConfigHash> {
|
||||||
|
assert(getIsPrettierAvailable());
|
||||||
|
|
||||||
|
if (getPrettier.cache !== undefined) {
|
||||||
|
return getPrettier.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prettier = id<typeof import("prettier") | undefined>(undefined);
|
||||||
|
|
||||||
|
import_prettier: {
|
||||||
|
// NOTE: When module is linked we want to make sure we import the correct version
|
||||||
|
// of prettier, that is the one of the project, not the one of this repo.
|
||||||
|
// So we do a sketchy eval to bypass ncc.
|
||||||
|
// We make sure to only do that when linking, otherwise we import properly.
|
||||||
|
if (readThisNpmPackageVersion() === "0.0.0") {
|
||||||
|
eval(
|
||||||
|
`${symToStr({ prettier })} = require("${pathResolve(pathJoin(getNodeModulesBinDirPath(), "..", "prettier"))}")`
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(!is<undefined>(prettier));
|
||||||
|
|
||||||
|
break import_prettier;
|
||||||
|
}
|
||||||
|
|
||||||
|
prettier = await import("prettier");
|
||||||
|
}
|
||||||
|
|
||||||
|
const configHash = await (async () => {
|
||||||
|
const configFilePath = await prettier.resolveConfigFile(
|
||||||
|
pathJoin(getNodeModulesBinDirPath(), "..")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (configFilePath === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fsPr.readFile(configFilePath);
|
||||||
|
|
||||||
|
return crypto.createHash("sha256").update(data).digest("hex");
|
||||||
|
})();
|
||||||
|
|
||||||
|
const prettierAndConfig: PrettierAndConfigHash = {
|
||||||
|
prettier,
|
||||||
|
configHash
|
||||||
|
};
|
||||||
|
|
||||||
|
getPrettier.cache = prettierAndConfig;
|
||||||
|
|
||||||
|
return prettierAndConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPrettier(params: {
|
||||||
|
sourceCode: string;
|
||||||
|
filePath: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const { sourceCode, filePath } = params;
|
||||||
|
|
||||||
|
let formattedSourceCode: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { prettier } = await getPrettier();
|
||||||
|
|
||||||
|
const { ignored, inferredParser } = await prettier.getFileInfo(filePath, {
|
||||||
|
resolveConfig: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ignored) {
|
||||||
|
return sourceCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await prettier.resolveConfig(filePath);
|
||||||
|
|
||||||
|
formattedSourceCode = await prettier.format(sourceCode, {
|
||||||
|
...config,
|
||||||
|
filePath,
|
||||||
|
parser: inferredParser ?? undefined
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
chalk.red(
|
||||||
|
`You probably need to upgrade the version of prettier in your project`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedSourceCode;
|
||||||
|
}
|
24
src/bin/tools/untrackFromGit.ts
Normal file
24
src/bin/tools/untrackFromGit.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import * as child_process from "child_process";
|
||||||
|
import { dirname as pathDirname, basename as pathBasename } from "path";
|
||||||
|
import { Deferred } from "evt/tools/Deferred";
|
||||||
|
|
||||||
|
export async function untrackFromGit(params: { filePath: string }): Promise<void> {
|
||||||
|
const { filePath } = params;
|
||||||
|
|
||||||
|
const dDone = new Deferred<void>();
|
||||||
|
|
||||||
|
child_process.exec(
|
||||||
|
`git rm --cached ${pathBasename(filePath)}`,
|
||||||
|
{ cwd: pathDirname(filePath) },
|
||||||
|
error => {
|
||||||
|
if (error !== null) {
|
||||||
|
dDone.reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dDone.resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await dDone.pr;
|
||||||
|
}
|
@ -1,13 +1,146 @@
|
|||||||
import type { CliCommandOptions } from "./main";
|
import type { BuildContext } from "./shared/buildContext";
|
||||||
import { getBuildContext } from "./shared/buildContext";
|
import * as fs from "fs/promises";
|
||||||
import { generateKcGenTs } from "./shared/generateKcGenTs";
|
import { join as pathJoin } from "path";
|
||||||
|
import { existsAsync } from "./tools/fs.existsAsync";
|
||||||
|
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
import { getIsPrettierAvailable, runPrettier } from "./tools/runPrettier";
|
||||||
|
|
||||||
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
|
export async function command(params: { buildContext: BuildContext }) {
|
||||||
const { cliCommandOptions } = params;
|
const { buildContext } = params;
|
||||||
|
|
||||||
const buildContext = getBuildContext({
|
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||||
cliCommandOptions
|
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 hasAdminTheme = buildContext.implementedThemeTypes.admin.isImplemented;
|
||||||
|
|
||||||
|
let 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
|
||||||
|
)};`,
|
||||||
|
``,
|
||||||
|
`type KcContext =`,
|
||||||
|
hasLoginTheme && ` | import("./login/KcContext").KcContext`,
|
||||||
|
hasAccountTheme && ` | import("./account/KcContext").KcContext`,
|
||||||
|
hasAdminTheme && ` | import("./admin/KcContext").KcContext`,
|
||||||
|
` ;`,
|
||||||
|
``,
|
||||||
|
`declare global {`,
|
||||||
|
` interface Window {`,
|
||||||
|
` kcContext?: KcContext;`,
|
||||||
|
` }`,
|
||||||
|
`}`,
|
||||||
|
``,
|
||||||
|
hasLoginTheme &&
|
||||||
|
`export const KcLoginPage = lazy(() => import("./login/KcPage"));`,
|
||||||
|
hasAccountTheme &&
|
||||||
|
`export const KcAccountPage = lazy(() => import("./account/KcPage"));`,
|
||||||
|
hasAdminTheme &&
|
||||||
|
`export const KcAdminPage = lazy(() => import("./admin/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} />;`,
|
||||||
|
hasAdminTheme &&
|
||||||
|
` case "admin": return <KcAdminPage 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
newContent = [
|
||||||
|
`// This file is auto-generated by the \`update-kc-gen\` command. Do not edit it manually.`,
|
||||||
|
`// Hash: ${hash}`,
|
||||||
|
``,
|
||||||
|
newContent
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
format: {
|
||||||
|
if (!(await getIsPrettierAvailable())) {
|
||||||
|
break format;
|
||||||
|
}
|
||||||
|
|
||||||
|
newContent = await runPrettier({
|
||||||
|
filePath,
|
||||||
|
sourceCode: newContent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, Buffer.from(newContent, "utf8"));
|
||||||
|
|
||||||
|
delete_legacy_file: {
|
||||||
|
const legacyFilePath = filePath.replace(/tsx$/, "ts");
|
||||||
|
|
||||||
|
if (!(await existsAsync(legacyFilePath))) {
|
||||||
|
break delete_legacy_file;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.unlink(legacyFilePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
252
src/lib/kcSanitize/HtmlPolicyBuilder.ts
Normal file
252
src/lib/kcSanitize/HtmlPolicyBuilder.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
60
src/lib/kcSanitize/KcSanitizer.ts
Normal file
60
src/lib/kcSanitize/KcSanitizer.ts
Normal 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(/=/g, "=")
|
||||||
|
.replace(/\.\./g, ".")
|
||||||
|
.replace(/&/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("");
|
||||||
|
}
|
||||||
|
}
|
294
src/lib/kcSanitize/KcSanitizerPolicy.ts
Normal file
294
src/lib/kcSanitize/KcSanitizerPolicy.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
5
src/lib/kcSanitize/index.ts
Normal file
5
src/lib/kcSanitize/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { KcSanitizer } from "./KcSanitizer";
|
||||||
|
|
||||||
|
export function kcSanitize(html: string): string {
|
||||||
|
return KcSanitizer.sanitize(html, {});
|
||||||
|
}
|
@ -2,7 +2,7 @@ import type { ThemeType, LoginThemePageId } from "keycloakify/bin/shared/constan
|
|||||||
import type { ValueOf } from "keycloakify/tools/ValueOf";
|
import type { ValueOf } from "keycloakify/tools/ValueOf";
|
||||||
import { assert } from "tsafe/assert";
|
import { assert } from "tsafe/assert";
|
||||||
import type { Equals } from "tsafe";
|
import type { Equals } from "tsafe";
|
||||||
import type { ClassKey } from "keycloakify/login/TemplateProps";
|
import type { ClassKey } from "keycloakify/login/lib/kcClsx";
|
||||||
|
|
||||||
export type ExtendKcContext<
|
export type ExtendKcContext<
|
||||||
KcContextExtension extends { properties?: Record<string, string | undefined> },
|
KcContextExtension extends { properties?: Record<string, string | undefined> },
|
||||||
@ -94,6 +94,7 @@ export declare namespace KcContext {
|
|||||||
languageTag: string;
|
languageTag: string;
|
||||||
}[];
|
}[];
|
||||||
currentLanguageTag: string;
|
currentLanguageTag: string;
|
||||||
|
rtl?: boolean;
|
||||||
};
|
};
|
||||||
auth?: {
|
auth?: {
|
||||||
showUsername?: boolean;
|
showUsername?: boolean;
|
||||||
@ -101,7 +102,7 @@ export declare namespace KcContext {
|
|||||||
showTryAnotherWayLink?: boolean;
|
showTryAnotherWayLink?: boolean;
|
||||||
attemptedUsername?: string;
|
attemptedUsername?: string;
|
||||||
};
|
};
|
||||||
scripts: string[];
|
scripts?: string[];
|
||||||
message?: {
|
message?: {
|
||||||
type: "success" | "warning" | "error" | "info";
|
type: "success" | "warning" | "error" | "info";
|
||||||
summary: string;
|
summary: string;
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
import { id } from "tsafe/id";
|
import { id } from "tsafe/id";
|
||||||
import { assert, type Equals } from "tsafe/assert";
|
import { assert, type Equals } from "tsafe/assert";
|
||||||
import { BASE_URL } from "keycloakify/lib/BASE_URL";
|
import { BASE_URL } from "keycloakify/lib/BASE_URL";
|
||||||
|
import type { LanguageTag } from "keycloakify/login/i18n/messages_defaultSet/types";
|
||||||
|
|
||||||
const attributesByName = Object.fromEntries(
|
const attributesByName = Object.fromEntries(
|
||||||
id<Attribute[]>([
|
id<Attribute[]>([
|
||||||
@ -116,7 +117,8 @@ export const kcContextCommonMock: KcContext.Common = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
locale: {
|
locale: {
|
||||||
supported: [
|
supported: (
|
||||||
|
[
|
||||||
/* spell-checker: disable */
|
/* spell-checker: disable */
|
||||||
["de", "Deutsch"],
|
["de", "Deutsch"],
|
||||||
["no", "Norsk"],
|
["no", "Norsk"],
|
||||||
@ -135,16 +137,39 @@ export const kcContextCommonMock: KcContext.Common = {
|
|||||||
["pl", "Polski"],
|
["pl", "Polski"],
|
||||||
["ca", "Català"],
|
["ca", "Català"],
|
||||||
["nl", "Nederlands"],
|
["nl", "Nederlands"],
|
||||||
["tr", "Türkçe"]
|
["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 */
|
/* spell-checker: enable */
|
||||||
].map(
|
] as const
|
||||||
([languageTag, label]) =>
|
).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,
|
languageTag,
|
||||||
label,
|
label,
|
||||||
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
|
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
|
||||||
}) as const
|
} as const;
|
||||||
),
|
}),
|
||||||
|
|
||||||
currentLanguageTag: "en"
|
currentLanguageTag: "en"
|
||||||
},
|
},
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { assert } from "keycloakify/tools/assert";
|
|
||||||
import { clsx } from "keycloakify/tools/clsx";
|
import { clsx } from "keycloakify/tools/clsx";
|
||||||
|
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||||
import type { TemplateProps } from "keycloakify/login/TemplateProps";
|
import type { TemplateProps } from "keycloakify/login/TemplateProps";
|
||||||
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
|
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
|
||||||
import { useSetClassName } from "keycloakify/tools/useSetClassName";
|
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 { I18n } from "./i18n";
|
||||||
import type { KcContext } from "./KcContext";
|
import type { KcContext } from "./KcContext";
|
||||||
|
|
||||||
@ -27,9 +27,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|||||||
|
|
||||||
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
|
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(() => {
|
useEffect(() => {
|
||||||
document.title = documentTitle ?? msgStr("loginTitle", kcContext.realm.displayName);
|
document.title = documentTitle ?? msgStr("loginTitle", kcContext.realm.displayName);
|
||||||
@ -45,7 +45,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|||||||
className: bodyClassName ?? kcClsx("kcBodyClass")
|
className: bodyClassName ?? kcClsx("kcBodyClass")
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isReadyToRender } = useStylesAndScripts({ kcContext, doUseDefaultCss });
|
const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss });
|
||||||
|
|
||||||
if (!isReadyToRender) {
|
if (!isReadyToRender) {
|
||||||
return null;
|
return null;
|
||||||
@ -58,10 +58,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|||||||
{msg("loginTitleHtml", realm.displayNameHtml)}
|
{msg("loginTitleHtml", realm.displayNameHtml)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={kcClsx("kcFormCardClass")}>
|
<div className={kcClsx("kcFormCardClass")}>
|
||||||
<header className={kcClsx("kcFormHeaderClass")}>
|
<header className={kcClsx("kcFormHeaderClass")}>
|
||||||
{realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && (
|
{enabledLanguages.length > 1 && (
|
||||||
<div className={kcClsx("kcLocaleMainClass")} id="kc-locale">
|
<div className={kcClsx("kcLocaleMainClass")} id="kc-locale">
|
||||||
<div id="kc-locale-wrapper" className={kcClsx("kcLocaleWrapperClass")}>
|
<div id="kc-locale-wrapper" className={kcClsx("kcLocaleWrapperClass")}>
|
||||||
<div id="kc-locale-dropdown" className={clsx("menu-button-links", kcClsx("kcLocaleDropDownClass"))}>
|
<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-expanded="false"
|
||||||
aria-controls="language-switch1"
|
aria-controls="language-switch1"
|
||||||
>
|
>
|
||||||
{labelBySupportedLanguageTag[currentLanguageTag]}
|
{currentLanguage.label}
|
||||||
</button>
|
</button>
|
||||||
<ul
|
<ul
|
||||||
role="menu"
|
role="menu"
|
||||||
@ -83,15 +82,10 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|||||||
id="language-switch1"
|
id="language-switch1"
|
||||||
className={kcClsx("kcLocaleListClass")}
|
className={kcClsx("kcLocaleListClass")}
|
||||||
>
|
>
|
||||||
{locale.supported.map(({ languageTag }, i) => (
|
{enabledLanguages.map(({ languageTag, label, href }, i) => (
|
||||||
<li key={languageTag} className={kcClsx("kcLocaleListItemClass")} role="none">
|
<li key={languageTag} className={kcClsx("kcLocaleListItemClass")} role="none">
|
||||||
<a
|
<a role="menuitem" id={`language-${i + 1}`} className={kcClsx("kcLocaleItemClass")} href={href}>
|
||||||
role="menuitem"
|
{label}
|
||||||
id={`language-${i + 1}`}
|
|
||||||
className={kcClsx("kcLocaleItemClass")}
|
|
||||||
href={getChangeLocaleUrl(languageTag)}
|
|
||||||
>
|
|
||||||
{labelBySupportedLanguageTag[languageTag]}
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@ -152,7 +146,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|||||||
<span
|
<span
|
||||||
className={kcClsx("kcAlertTitleClass")}
|
className={kcClsx("kcAlertTitleClass")}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: message.summary
|
__html: kcSanitize(message.summary)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,7 +2,7 @@ import { useEffect } from "react";
|
|||||||
import { assert } from "keycloakify/tools/assert";
|
import { assert } from "keycloakify/tools/assert";
|
||||||
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
|
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
|
||||||
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
|
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
|
||||||
import { KcContext } from "keycloakify/login/KcContext/KcContext";
|
import type { KcContext } from "keycloakify/login/KcContext";
|
||||||
|
|
||||||
export type KcContextLike = {
|
export type KcContextLike = {
|
||||||
url: {
|
url: {
|
||||||
@ -10,34 +10,19 @@ export type KcContextLike = {
|
|||||||
resourcesPath: string;
|
resourcesPath: string;
|
||||||
ssoLoginInOtherTabsUrl: string;
|
ssoLoginInOtherTabsUrl: string;
|
||||||
};
|
};
|
||||||
locale?: {
|
scripts?: string[];
|
||||||
currentLanguageTag: string;
|
|
||||||
};
|
|
||||||
scripts: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
assert<keyof KcContextLike extends keyof KcContext ? true : false>();
|
assert<keyof KcContextLike extends keyof KcContext ? true : false>();
|
||||||
assert<KcContext extends KcContextLike ? true : false>();
|
assert<KcContext extends KcContextLike ? true : false>();
|
||||||
|
|
||||||
export function useStylesAndScripts(params: {
|
export function useInitialize(params: {
|
||||||
kcContext: KcContextLike;
|
kcContext: KcContextLike;
|
||||||
doUseDefaultCss: boolean;
|
doUseDefaultCss: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { kcContext, doUseDefaultCss } = params;
|
const { kcContext, doUseDefaultCss } = params;
|
||||||
|
|
||||||
const { url, locale, scripts } = kcContext;
|
const { url, scripts } = kcContext;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { currentLanguageTag } = locale ?? {};
|
|
||||||
|
|
||||||
if (currentLanguageTag === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = document.querySelector("html");
|
|
||||||
assert(html !== null);
|
|
||||||
html.lang = currentLanguageTag;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
|
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
|
||||||
componentOrHookName: "Template",
|
componentOrHookName: "Template",
|
||||||
@ -60,18 +45,18 @@ export function useStylesAndScripts(params: {
|
|||||||
type: "module",
|
type: "module",
|
||||||
src: `${url.resourcesPath}/js/menu-button-links.js`
|
src: `${url.resourcesPath}/js/menu-button-links.js`
|
||||||
},
|
},
|
||||||
...scripts.map(src => ({
|
...(scripts === undefined
|
||||||
|
? []
|
||||||
|
: scripts.map(src => ({
|
||||||
type: "text/javascript" as const,
|
type: "text/javascript" as const,
|
||||||
src
|
src
|
||||||
})),
|
}))),
|
||||||
{
|
{
|
||||||
type: "module",
|
type: "module",
|
||||||
textContent: `
|
textContent: `
|
||||||
import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";
|
import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";
|
||||||
|
|
||||||
checkCookiesAndSetTimer(
|
checkCookiesAndSetTimer("${url.ssoLoginInOtherTabsUrl}");
|
||||||
"${url.ssoLoginInOtherTabsUrl}"
|
|
||||||
);
|
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
]
|
]
|
@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import type { ClassKey } from "keycloakify/login/lib/kcClsx";
|
||||||
|
|
||||||
export type TemplateProps<KcContext, I18n> = {
|
export type TemplateProps<KcContext, I18n> = {
|
||||||
kcContext: KcContext;
|
kcContext: KcContext;
|
||||||
@ -18,128 +19,4 @@ export type TemplateProps<KcContext, I18n> = {
|
|||||||
bodyClassName?: string;
|
bodyClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClassKey =
|
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";
|
|
||||||
|
@ -434,9 +434,7 @@ function AddRemoveButtonsMultiValuedAttribute(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InputTagSelects(props: InputFieldByTypeProps) {
|
function InputTagSelects(props: InputFieldByTypeProps) {
|
||||||
const { attribute, dispatchFormAction, kcClsx, valueOrValues } = props;
|
const { attribute, dispatchFormAction, kcClsx, i18n, valueOrValues } = props;
|
||||||
|
|
||||||
const { advancedMsg } = props.i18n;
|
|
||||||
|
|
||||||
const { classDiv, classInput, classLabel, inputType } = (() => {
|
const { classDiv, classInput, classLabel, inputType } = (() => {
|
||||||
const { inputType } = attribute.annotations;
|
const { inputType } = attribute.annotations;
|
||||||
@ -533,7 +531,7 @@ function InputTagSelects(props: InputFieldByTypeProps) {
|
|||||||
htmlFor={`${attribute.name}-${option}`}
|
htmlFor={`${attribute.name}-${option}`}
|
||||||
className={`${classLabel}${attribute.readOnly ? ` ${kcClsx("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
|
className={`${classLabel}${attribute.readOnly ? ` ${kcClsx("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
|
||||||
>
|
>
|
||||||
{advancedMsg(option)}
|
{inputLabel(i18n, attribute, option)}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -580,8 +578,6 @@ function TextareaTag(props: InputFieldByTypeProps) {
|
|||||||
function SelectTag(props: InputFieldByTypeProps) {
|
function SelectTag(props: InputFieldByTypeProps) {
|
||||||
const { attribute, dispatchFormAction, kcClsx, displayableErrors, i18n, valueOrValues } = props;
|
const { attribute, dispatchFormAction, kcClsx, displayableErrors, i18n, valueOrValues } = props;
|
||||||
|
|
||||||
const { advancedMsgStr } = i18n;
|
|
||||||
|
|
||||||
const isMultiple = attribute.annotations.inputType === "multiselect";
|
const isMultiple = attribute.annotations.inputType === "multiselect";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -645,22 +641,26 @@ function SelectTag(props: InputFieldByTypeProps) {
|
|||||||
|
|
||||||
return options.map(option => (
|
return options.map(option => (
|
||||||
<option key={option} value={option}>
|
<option key={option} value={option}>
|
||||||
{(() => {
|
{inputLabel(i18n, attribute, 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;
|
|
||||||
})()}
|
|
||||||
</option>
|
</option>
|
||||||
));
|
));
|
||||||
})()}
|
})()}
|
||||||
</select>
|
</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;
|
||||||
|
}
|
||||||
|
@ -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;
|
|
||||||
};
|
|
@ -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 <strong>XXX</strong>"
|
|
||||||
* 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, "<").replace(/>/g, ">");
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
@ -1,5 +1,5 @@
|
|||||||
import type { GenericI18n } from "./GenericI18n";
|
export * from "./withJsx";
|
||||||
import type { MessageKey_defaultSet, KcContextLike } from "./i18n";
|
import type { GenericI18n } from "./withJsx/GenericI18n";
|
||||||
export type { MessageKey_defaultSet, KcContextLike };
|
import type { MessageKey as MessageKey_defaultSet } from "./messages_defaultSet/types";
|
||||||
export type I18n = GenericI18n<MessageKey_defaultSet>;
|
/** INTERNAL: DO NOT IMPORT THIS */
|
||||||
export { createUseI18n } from "./useI18n";
|
export type I18n = GenericI18n<MessageKey_defaultSet, string>;
|
||||||
|
64
src/login/i18n/noJsx/GenericI18n_noJsx.ts
Normal file
64
src/login/i18n/noJsx/GenericI18n_noJsx.ts
Normal 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 <strong>XXX</strong>"
|
||||||
|
* 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;
|
||||||
|
};
|
390
src/login/i18n/noJsx/getI18n.tsx
Normal file
390
src/login/i18n/noJsx/getI18n.tsx
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
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;
|
||||||
|
realm: {
|
||||||
|
internationalizationEnabled: boolean;
|
||||||
|
};
|
||||||
|
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 kcContextLocale = params.kcContext.realm.internationalizationEnabled ? params.kcContext.locale : undefined;
|
||||||
|
|
||||||
|
{
|
||||||
|
const currentLanguageTag = kcContextLocale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG;
|
||||||
|
const html = document.querySelector("html");
|
||||||
|
assert(html !== null);
|
||||||
|
html.lang = currentLanguageTag;
|
||||||
|
|
||||||
|
const isRtl = (() => {
|
||||||
|
const { rtl } = kcContextLocale ?? {};
|
||||||
|
|
||||||
|
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 (kcContextLocale === undefined) {
|
||||||
|
break from_server;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportedEntry = kcContextLocale.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>(kcContextLocale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG) as LanguageTag;
|
||||||
|
|
||||||
|
return {
|
||||||
|
languageTag,
|
||||||
|
label: getLanguageLabel(languageTag)
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const enabledLanguages: I18n["enabledLanguages"] = (() => {
|
||||||
|
const enabledLanguages: I18n["enabledLanguages"] = [];
|
||||||
|
|
||||||
|
if (kcContextLocale !== undefined) {
|
||||||
|
for (const entry of kcContextLocale.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 };
|
||||||
|
}
|
117
src/login/i18n/noJsx/i18nBuilder.ts
Normal file
117
src/login/i18n/noJsx/i18nBuilder.ts
Normal 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: {}
|
||||||
|
});
|
3
src/login/i18n/noJsx/index.ts
Normal file
3
src/login/i18n/noJsx/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { i18nBuilder } from "./i18nBuilder";
|
||||||
|
export type { KcContextLike } from "./getI18n";
|
||||||
|
export type { MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";
|
@ -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>() };
|
|
||||||
}
|
|
81
src/login/i18n/withJsx/GenericI18n.tsx
Normal file
81
src/login/i18n/withJsx/GenericI18n.tsx
Normal 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 <strong>XXX</strong>"
|
||||||
|
* 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>>;
|
||||||
|
}
|
117
src/login/i18n/withJsx/i18nBuilder.ts
Normal file
117
src/login/i18n/withJsx/i18nBuilder.ts
Normal 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: {}
|
||||||
|
});
|
3
src/login/i18n/withJsx/index.ts
Normal file
3
src/login/i18n/withJsx/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { i18nBuilder } from "./i18nBuilder";
|
||||||
|
export type { KcContextLike } from "./useI18n";
|
||||||
|
export type { MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";
|
128
src/login/i18n/withJsx/useI18n.tsx
Normal file
128
src/login/i18n/withJsx/useI18n.tsx
Normal 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>() };
|
||||||
|
}
|
@ -1,3 +1,3 @@
|
|||||||
export type { ExtendKcContext, Attribute } from "keycloakify/login/KcContext";
|
export type { ExtendKcContext, Attribute } from "keycloakify/login/KcContext";
|
||||||
export type { ClassKey } from "keycloakify/login/TemplateProps";
|
export type { ClassKey } from "keycloakify/login/TemplateProps";
|
||||||
export { createUseI18n } from "keycloakify/login/i18n";
|
export { i18nBuilder, type MessageKey_defaultSet } from "keycloakify/login/i18n";
|
||||||
|
1559
src/login/lib/getUserProfileApi/getUserProfileApi.ts
Normal file
1559
src/login/lib/getUserProfileApi/getUserProfileApi.ts
Normal file
File diff suppressed because it is too large
Load Diff
1
src/login/lib/getUserProfileApi/index.ts
Normal file
1
src/login/lib/getUserProfileApi/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./getUserProfileApi";
|
109
src/login/lib/getUserProfileApi/kcNumberUnFormat.ts
Normal file
109
src/login/lib/getUserProfileApi/kcNumberUnFormat.ts
Normal 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;
|
||||||
|
};
|
@ -1,5 +1,130 @@
|
|||||||
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
|
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>({
|
export const { getKcClsx } = createGetKcClsx<ClassKey>({
|
||||||
defaultClasses: {
|
defaultClasses: {
|
||||||
@ -138,6 +263,4 @@ export const { getKcClsx } = createGetKcClsx<ClassKey>({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type { ClassKey };
|
|
||||||
|
|
||||||
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];
|
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user