Compare commits
94 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
d24a8e99cc | ||
|
59d2d56091 | ||
|
8515b7060a | ||
|
a076b3f4d0 | ||
|
1e3240ef35 | ||
|
d767080dfe | ||
|
b228eda488 | ||
|
35f54964ce | ||
|
759b834ccc | ||
|
137e12cbbb | ||
|
57b08d9dea | ||
|
1dd7b673a1 | ||
|
b00ffc50c3 | ||
|
f9db40d33d | ||
|
4bc6a843d8 | ||
|
da3e7514f0 | ||
|
bc396bc41b | ||
|
947efe8d63 | ||
|
64189bf8fe | ||
|
400c630418 | ||
|
402360b436 | ||
|
9f001f1521 | ||
|
368e3a32c5 | ||
|
002e3d4b3d | ||
|
f94f9b51c9 | ||
|
055b15bd46 | ||
|
0e70b0b0de | ||
|
8faf9a3eed | ||
|
075d9f9de5 | ||
|
840079be32 | ||
|
50ae962f09 | ||
|
61aa1f9896 | ||
|
d88e0e4dd5 | ||
|
18c36eb4de | ||
|
80aeabad51 | ||
|
419e1f473a | ||
|
80988125e8 | ||
|
271ad2da71 | ||
|
b2732f2595 | ||
|
53820e1e34 | ||
|
09dd45e437 | ||
|
1f654a7820 | ||
|
0690f40bad | ||
|
2285883149 | ||
|
af87e41bb8 | ||
|
9ba884483d | ||
|
f5a300953a | ||
|
ab9a962f58 | ||
|
484adb607f | ||
|
e1f38d4196 | ||
|
5de629acf2 | ||
|
8b4b24a036 | ||
|
75ab130249 | ||
|
981ca7e9a4 | ||
|
acb4e260a7 | ||
|
ff20b0a844 | ||
|
1b77c69a01 | ||
|
158275f5c2 | ||
|
a085c8093e | ||
|
cb358bd745 | ||
|
e788c46601 | ||
|
d551b4bffb | ||
|
c168c7b156 | ||
|
7a46115042 | ||
|
249a7bde89 | ||
|
813740a002 | ||
|
7840c2a6f5 | ||
|
8f6c0d36d9 | ||
|
12690b892b | ||
|
d01b4b71c9 | ||
|
c29e600786 | ||
|
6309b7c45d | ||
|
7e7996e40c | ||
|
deaeab0f61 | ||
|
6bd5451230 | ||
|
fb2d651a6f | ||
|
4845d7c32d | ||
|
c33c315120 | ||
|
99b8f1e789 | ||
|
6af13e1405 | ||
|
f59fa4238c | ||
|
248effc57d | ||
|
9e540b2c1f | ||
|
ab7b5ff490 | ||
|
486f944e0f | ||
|
6cc3d4c442 | ||
|
083290c6d4 | ||
|
cd1b55b850 | ||
|
482ba6c639 | ||
|
e2921b7e37 | ||
|
c87b6153bb | ||
|
488dd2c6b9 | ||
|
1ac678a368 | ||
|
5866c802e5 |
@ -327,6 +327,42 @@
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "EternalSide",
|
||||
"name": "Lesha",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/118743608?v=4",
|
||||
"profile": "http://t.me/AAT_L",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "bacongobbler",
|
||||
"name": "Matthew Fisher",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1360539?v=4",
|
||||
"profile": "https://blog.bacongobbler.com",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "kodebach",
|
||||
"name": "Klemens Böswirth",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/23529132?v=4",
|
||||
"profile": "https://github.com/kodebach",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "wnmzzzz",
|
||||
"name": "wnmzzzz",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/117174301?v=4",
|
||||
"profile": "https://github.com/wnmzzzz",
|
||||
"contributions": [
|
||||
"test"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
1
.github/FUNDING.yaml
vendored
1
.github/FUNDING.yaml
vendored
@ -1,4 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [garronej]
|
||||
custom: ['https://www.ringerhq.com/experts/garronej']
|
||||
|
10
README.md
10
README.md
@ -6,7 +6,7 @@
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://github.com/garronej/keycloakify/actions">
|
||||
<img src="https://github.com/garronej/keycloakify/workflows/ci/badge.svg?branch=main">
|
||||
<img src="https://github.com/keycloakify/keycloakify/actions/workflows/ci.yaml/badge.svg">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/keycloakify">
|
||||
<img src="https://img.shields.io/npm/dm/keycloakify">
|
||||
@ -46,7 +46,7 @@ Keycloakify is fully compatible with Keycloak from version 11 to 26...[and beyon
|
||||
> 📣 **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).
|
||||
> No breaking changes have been introduced, but the target version ranges have been updated. For more details, see [this guide](https://docs.keycloakify.dev/features/compiler-options/keycloakversiontargets).
|
||||
|
||||
## Sponsors
|
||||
|
||||
@ -168,6 +168,12 @@ 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/zvn2060"><img src="https://avatars.githubusercontent.com/u/45450852?v=4?s=100" width="100px;" alt="HI_OuO"/><br /><sub><b>HI_OuO</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=zvn2060" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tripheo0412"><img src="https://avatars.githubusercontent.com/u/25382052?v=4?s=100" width="100px;" alt="Tri Hoang"/><br /><sub><b>Tri Hoang</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=tripheo0412" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://t.me/AAT_L"><img src="https://avatars.githubusercontent.com/u/118743608?v=4?s=100" width="100px;" alt="Lesha"/><br /><sub><b>Lesha</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=EternalSide" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://blog.bacongobbler.com"><img src="https://avatars.githubusercontent.com/u/1360539?v=4?s=100" width="100px;" alt="Matthew Fisher"/><br /><sub><b>Matthew Fisher</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=bacongobbler" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kodebach"><img src="https://avatars.githubusercontent.com/u/23529132?v=4?s=100" width="100px;" alt="Klemens Böswirth"/><br /><sub><b>Klemens Böswirth</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kodebach" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wnmzzzz"><img src="https://avatars.githubusercontent.com/u/117174301?v=4?s=100" width="100px;" alt="wnmzzzz"/><br /><sub><b>wnmzzzz</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=wnmzzzz" title="Tests">⚠️</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "keycloakify",
|
||||
"version": "11.6.2",
|
||||
"version": "11.8.23",
|
||||
"description": "Framework to create custom Keycloak UIs",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -280,6 +280,24 @@ export async function downloadKeycloakDefaultTheme(params: {
|
||||
"fonts",
|
||||
"OpenSans-Semibold-webfont.woff2"
|
||||
),
|
||||
pathJoin(
|
||||
"patternfly",
|
||||
"dist",
|
||||
"fonts",
|
||||
"OpenSans-SemiboldItalic-webfont.woff2"
|
||||
),
|
||||
pathJoin(
|
||||
"patternfly",
|
||||
"dist",
|
||||
"fonts",
|
||||
"OpenSans-SemiboldItalic-webfont.woff"
|
||||
),
|
||||
pathJoin(
|
||||
"patternfly",
|
||||
"dist",
|
||||
"fonts",
|
||||
"OpenSans-SemiboldItalic-webfont.ttf"
|
||||
),
|
||||
pathJoin("patternfly", "dist", "img", "bg-login.jpg"),
|
||||
pathJoin("jquery", "dist", "jquery.min.js"),
|
||||
pathJoin("rfc4648", "lib", "rfc4648.js")
|
||||
|
@ -20,7 +20,7 @@ import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_dele
|
||||
export async function command(params: { buildContext: BuildContext }) {
|
||||
const { buildContext } = params;
|
||||
|
||||
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
|
||||
commandName: "add-story",
|
||||
buildContext
|
||||
});
|
||||
@ -74,7 +74,7 @@ export async function command(params: { buildContext: BuildContext }) {
|
||||
|
||||
if (themeType === "admin") {
|
||||
console.log(
|
||||
`${chalk.red("✗")} Sorry, there is no Storybook support for the Account UI.`
|
||||
`${chalk.red("✗")} Sorry, there is no Storybook support for the Admin UI.`
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
|
@ -11,7 +11,7 @@ import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
|
||||
export async function command(params: { buildContext: BuildContext }) {
|
||||
const { buildContext } = params;
|
||||
|
||||
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
|
||||
commandName: "copy-keycloak-resources-to-public",
|
||||
buildContext
|
||||
});
|
||||
|
@ -11,12 +11,7 @@ import {
|
||||
} from "./shared/constants";
|
||||
import { capitalize } from "tsafe/capitalize";
|
||||
import * as fs from "fs";
|
||||
import {
|
||||
join as pathJoin,
|
||||
relative as pathRelative,
|
||||
dirname as pathDirname,
|
||||
basename as pathBasename
|
||||
} from "path";
|
||||
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
|
||||
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
|
||||
import { assert, Equals } from "tsafe/assert";
|
||||
import type { BuildContext } from "./shared/buildContext";
|
||||
@ -27,7 +22,7 @@ import { runPrettier, getIsPrettierAvailable } from "./tools/runPrettier";
|
||||
export async function command(params: { buildContext: BuildContext }) {
|
||||
const { buildContext } = params;
|
||||
|
||||
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
|
||||
commandName: "eject-page",
|
||||
buildContext
|
||||
});
|
||||
@ -77,85 +72,16 @@ export async function command(params: { buildContext: BuildContext }) {
|
||||
(assert(buildContext.implementedThemeTypes.account.isImplemented),
|
||||
buildContext.implementedThemeTypes.account.type === "Single-Page")
|
||||
) {
|
||||
const srcDirPath = pathJoin(
|
||||
pathDirname(buildContext.packageJsonFilePath),
|
||||
"node_modules",
|
||||
"@keycloakify",
|
||||
`keycloak-account-ui`,
|
||||
"src"
|
||||
);
|
||||
|
||||
console.log(
|
||||
[
|
||||
`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:`,
|
||||
``,
|
||||
`${chalk.bold(pathJoin(pathRelative(process.cwd(), srcDirPath)))}`,
|
||||
``
|
||||
].join("\n")
|
||||
chalk.yellow(
|
||||
[
|
||||
"You are implementing a Single-Page Account theme.",
|
||||
"The eject-page command isn't applicable in this context"
|
||||
].join("\n")
|
||||
)
|
||||
);
|
||||
|
||||
eject_entrypoint: {
|
||||
const kcUiTsxFileRelativePath = `KcAccountUi.tsx` as const;
|
||||
|
||||
const themeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
|
||||
|
||||
const targetFilePath = pathJoin(themeSrcDirPath, kcUiTsxFileRelativePath);
|
||||
|
||||
if (fs.existsSync(targetFilePath)) {
|
||||
break eject_entrypoint;
|
||||
}
|
||||
|
||||
fs.cpSync(pathJoin(srcDirPath, kcUiTsxFileRelativePath), targetFilePath);
|
||||
|
||||
{
|
||||
const kcPageTsxFilePath = pathJoin(themeSrcDirPath, "KcPage.tsx");
|
||||
|
||||
const kcPageTsxCode = fs.readFileSync(kcPageTsxFilePath).toString("utf8");
|
||||
|
||||
const componentName = pathBasename(kcUiTsxFileRelativePath).replace(
|
||||
/.tsx$/,
|
||||
""
|
||||
);
|
||||
|
||||
let modifiedKcPageTsxCode = kcPageTsxCode.replace(
|
||||
`@keycloakify/keycloak-account-ui/${componentName}`,
|
||||
`./${componentName}`
|
||||
);
|
||||
|
||||
run_prettier: {
|
||||
if (!(await getIsPrettierAvailable())) {
|
||||
break run_prettier;
|
||||
}
|
||||
|
||||
modifiedKcPageTsxCode = await runPrettier({
|
||||
filePath: kcPageTsxFilePath,
|
||||
sourceCode: modifiedKcPageTsxCode
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
kcPageTsxFilePath,
|
||||
Buffer.from(modifiedKcPageTsxCode, "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
const routesTsxFilePath = pathRelative(
|
||||
process.cwd(),
|
||||
pathJoin(srcDirPath, "routes.tsx")
|
||||
);
|
||||
|
||||
console.log(
|
||||
[
|
||||
`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)}`,
|
||||
`with \`cp ${routesTsxFilePath} ${pathRelative(process.cwd(), themeSrcDirPath)}\``,
|
||||
`then update the import of routes in ${kcUiTsxFileRelativePath}.`
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -166,12 +92,14 @@ export async function command(params: { buildContext: BuildContext }) {
|
||||
const templateValue = "Template.tsx (Layout common to every page)";
|
||||
const userProfileFormFieldsValue =
|
||||
"UserProfileFormFields.tsx (Renders the form of the register.ftl, login-update-profile.ftl, update-email.ftl and idp-review-user-profile.ftl)";
|
||||
const otherPageValue = "The page you're looking for isn't listed here";
|
||||
|
||||
const { value: pageIdOrComponent } = await cliSelect<
|
||||
| LoginThemePageId
|
||||
| AccountThemePageId
|
||||
| typeof templateValue
|
||||
| typeof userProfileFormFieldsValue
|
||||
| typeof otherPageValue
|
||||
>({
|
||||
values: (() => {
|
||||
switch (themeType) {
|
||||
@ -179,10 +107,11 @@ export async function command(params: { buildContext: BuildContext }) {
|
||||
return [
|
||||
templateValue,
|
||||
userProfileFormFieldsValue,
|
||||
...LOGIN_THEME_PAGE_IDS
|
||||
...LOGIN_THEME_PAGE_IDS,
|
||||
otherPageValue
|
||||
];
|
||||
case "account":
|
||||
return [templateValue, ...ACCOUNT_THEME_PAGE_IDS];
|
||||
return [templateValue, ...ACCOUNT_THEME_PAGE_IDS, otherPageValue];
|
||||
}
|
||||
assert<Equals<typeof themeType, never>>(false);
|
||||
})()
|
||||
@ -190,6 +119,17 @@ export async function command(params: { buildContext: BuildContext }) {
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
if (pageIdOrComponent === otherPageValue) {
|
||||
console.log(
|
||||
[
|
||||
"To style a page not included in the base Keycloak, such as one added by a third-party Keycloak extension,",
|
||||
"refer to the documentation: https://docs.keycloakify.dev/features/styling-a-custom-page-not-included-in-base-keycloak"
|
||||
].join(" ")
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`→ ${pageIdOrComponent}`);
|
||||
|
||||
const componentBasename = (() => {
|
||||
|
@ -1,32 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin } from "path";
|
||||
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
|
||||
export function copyBoilerplate(params: {
|
||||
accountThemeType: "Single-Page" | "Multi-Page";
|
||||
accountThemeSrcDirPath: string;
|
||||
}) {
|
||||
const { accountThemeType, accountThemeSrcDirPath } = params;
|
||||
|
||||
fs.cpSync(
|
||||
pathJoin(
|
||||
getThisCodebaseRootDirPath(),
|
||||
"src",
|
||||
"bin",
|
||||
"initialize-account-theme",
|
||||
"src",
|
||||
(() => {
|
||||
switch (accountThemeType) {
|
||||
case "Single-Page":
|
||||
return "single-page";
|
||||
case "Multi-Page":
|
||||
return "multi-page";
|
||||
}
|
||||
assert<Equals<typeof accountThemeType, never>>(false);
|
||||
})()
|
||||
),
|
||||
accountThemeSrcDirPath,
|
||||
{ recursive: true }
|
||||
);
|
||||
}
|
@ -7,11 +7,12 @@ import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeIm
|
||||
import { command as updateKcGenCommand } from "../update-kc-gen";
|
||||
import { maybeDelegateCommandToCustomHandler } from "../shared/customHandler_delegate";
|
||||
import { exitIfUncommittedChanges } from "../shared/exitIfUncommittedChanges";
|
||||
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
|
||||
|
||||
export async function command(params: { buildContext: BuildContext }) {
|
||||
const { buildContext } = params;
|
||||
|
||||
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
|
||||
commandName: "initialize-account-theme",
|
||||
buildContext
|
||||
});
|
||||
@ -22,22 +23,6 @@ export async function command(params: { buildContext: BuildContext }) {
|
||||
|
||||
const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
|
||||
|
||||
if (
|
||||
fs.existsSync(accountThemeSrcDirPath) &&
|
||||
fs.readdirSync(accountThemeSrcDirPath).length > 0
|
||||
) {
|
||||
console.warn(
|
||||
chalk.red(
|
||||
`There is already a ${pathRelative(
|
||||
process.cwd(),
|
||||
accountThemeSrcDirPath
|
||||
)} directory in your project. Aborting.`
|
||||
)
|
||||
);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
exitIfUncommittedChanges({
|
||||
projectDirPath: buildContext.projectDirPath
|
||||
});
|
||||
@ -51,23 +36,41 @@ export async function command(params: { buildContext: BuildContext }) {
|
||||
switch (accountThemeType) {
|
||||
case "Multi-Page":
|
||||
{
|
||||
const { initializeAccountTheme_multiPage } = await import(
|
||||
"./initializeAccountTheme_multiPage"
|
||||
);
|
||||
if (
|
||||
fs.existsSync(accountThemeSrcDirPath) &&
|
||||
fs.readdirSync(accountThemeSrcDirPath).length > 0
|
||||
) {
|
||||
console.warn(
|
||||
chalk.red(
|
||||
`There is already a ${pathRelative(
|
||||
process.cwd(),
|
||||
accountThemeSrcDirPath
|
||||
)} directory in your project. Aborting.`
|
||||
)
|
||||
);
|
||||
|
||||
await initializeAccountTheme_multiPage({
|
||||
accountThemeSrcDirPath
|
||||
});
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
fs.cpSync(
|
||||
pathJoin(
|
||||
getThisCodebaseRootDirPath(),
|
||||
"src",
|
||||
"bin",
|
||||
"initialize-account-theme",
|
||||
"multi-page-boilerplate"
|
||||
),
|
||||
accountThemeSrcDirPath,
|
||||
{ recursive: true }
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "Single-Page":
|
||||
{
|
||||
const { initializeAccountTheme_singlePage } = await import(
|
||||
"./initializeAccountTheme_singlePage"
|
||||
);
|
||||
const { initializeSpa } = await import("../shared/initializeSpa");
|
||||
|
||||
await initializeAccountTheme_singlePage({
|
||||
accountThemeSrcDirPath,
|
||||
await initializeSpa({
|
||||
themeType: "account",
|
||||
buildContext
|
||||
});
|
||||
}
|
||||
|
@ -1,21 +0,0 @@
|
||||
import { relative as pathRelative } from "path";
|
||||
import chalk from "chalk";
|
||||
import { copyBoilerplate } from "./copyBoilerplate";
|
||||
|
||||
export async function initializeAccountTheme_multiPage(params: {
|
||||
accountThemeSrcDirPath: string;
|
||||
}) {
|
||||
const { accountThemeSrcDirPath } = params;
|
||||
|
||||
copyBoilerplate({
|
||||
accountThemeType: "Multi-Page",
|
||||
accountThemeSrcDirPath
|
||||
});
|
||||
|
||||
console.log(
|
||||
[
|
||||
chalk.green("The Multi-Page account theme has been initialized."),
|
||||
`Directory created: ${chalk.bold(pathRelative(process.cwd(), accountThemeSrcDirPath))}`
|
||||
].join("\n")
|
||||
);
|
||||
}
|
@ -1,142 +0,0 @@
|
||||
import { relative as pathRelative, dirname as pathDirname } from "path";
|
||||
import type { BuildContext } from "../shared/buildContext";
|
||||
import * as fs from "fs";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
getLatestsSemVersionedTag,
|
||||
type BuildContextLike as BuildContextLike_getLatestsSemVersionedTag
|
||||
} from "../shared/getLatestsSemVersionedTag";
|
||||
import { SemVer } from "../tools/SemVer";
|
||||
import fetch from "make-fetch-happen";
|
||||
import { z } from "zod";
|
||||
import { assert, type Equals, is } from "tsafe/assert";
|
||||
import { id } from "tsafe/id";
|
||||
import { npmInstall } from "../tools/npmInstall";
|
||||
import { copyBoilerplate } from "./copyBoilerplate";
|
||||
|
||||
type BuildContextLike = BuildContextLike_getLatestsSemVersionedTag & {
|
||||
fetchOptions: BuildContext["fetchOptions"];
|
||||
packageJsonFilePath: string;
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export async function initializeAccountTheme_singlePage(params: {
|
||||
accountThemeSrcDirPath: string;
|
||||
buildContext: BuildContextLike;
|
||||
}) {
|
||||
const { accountThemeSrcDirPath, buildContext } = params;
|
||||
|
||||
const OWNER = "keycloakify";
|
||||
const REPO = "keycloak-account-ui";
|
||||
|
||||
const [semVersionedTag] = await getLatestsSemVersionedTag({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
count: 1,
|
||||
doIgnoreReleaseCandidates: false,
|
||||
buildContext
|
||||
});
|
||||
|
||||
const dependencies = await fetch(
|
||||
`https://raw.githubusercontent.com/${OWNER}/${REPO}/${semVersionedTag.tag}/dependencies.gen.json`,
|
||||
buildContext.fetchOptions
|
||||
)
|
||||
.then(r => r.json())
|
||||
.then(
|
||||
(() => {
|
||||
type Dependencies = {
|
||||
dependencies: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
const zDependencies = (() => {
|
||||
type TargetType = Dependencies;
|
||||
|
||||
const zTargetType = z.object({
|
||||
dependencies: z.record(z.string()),
|
||||
devDependencies: z.record(z.string()).optional()
|
||||
});
|
||||
|
||||
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
|
||||
|
||||
return id<z.ZodType<TargetType>>(zTargetType);
|
||||
})();
|
||||
|
||||
return o => zDependencies.parse(o);
|
||||
})()
|
||||
);
|
||||
|
||||
dependencies.dependencies["@keycloakify/keycloak-account-ui"] = SemVer.stringify(
|
||||
semVersionedTag.version
|
||||
);
|
||||
|
||||
const parsedPackageJson = (() => {
|
||||
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);
|
||||
})();
|
||||
const parsedPackageJson = JSON.parse(
|
||||
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
|
||||
);
|
||||
|
||||
zParsedPackageJson.parse(parsedPackageJson);
|
||||
|
||||
assert(is<ParsedPackageJson>(parsedPackageJson));
|
||||
|
||||
return parsedPackageJson;
|
||||
})();
|
||||
|
||||
parsedPackageJson.dependencies = {
|
||||
...parsedPackageJson.dependencies,
|
||||
...dependencies.dependencies
|
||||
};
|
||||
|
||||
parsedPackageJson.devDependencies = {
|
||||
...parsedPackageJson.devDependencies,
|
||||
...dependencies.devDependencies
|
||||
};
|
||||
|
||||
if (Object.keys(parsedPackageJson.devDependencies).length === 0) {
|
||||
delete parsedPackageJson.devDependencies;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
buildContext.packageJsonFilePath,
|
||||
JSON.stringify(parsedPackageJson, undefined, 4)
|
||||
);
|
||||
|
||||
await npmInstall({
|
||||
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
|
||||
});
|
||||
|
||||
copyBoilerplate({
|
||||
accountThemeType: "Single-Page",
|
||||
accountThemeSrcDirPath
|
||||
});
|
||||
|
||||
console.log(
|
||||
[
|
||||
chalk.green(
|
||||
"The Single-Page account theme has been successfully initialized."
|
||||
),
|
||||
`Using Account UI of Keycloak version: ${chalk.bold(semVersionedTag.tag.split("-")[0])}`,
|
||||
`Directory created: ${chalk.bold(pathRelative(process.cwd(), accountThemeSrcDirPath))}`,
|
||||
`Dependencies added to your project's package.json: `,
|
||||
chalk.bold(JSON.stringify(dependencies, null, 2))
|
||||
].join("\n")
|
||||
);
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { i18nBuilder } from "keycloakify/account";
|
||||
import type { ThemeName } from "../kc.gen";
|
||||
|
||||
/** @see: https://docs.keycloakify.dev/i18n */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
/** @see: https://docs.keycloakify.dev/features/i18n */
|
||||
const { useI18n, ofTypeI18n } = i18nBuilder.withThemeName<ThemeName>().build();
|
||||
|
||||
type I18n = typeof ofTypeI18n;
|
@ -1,7 +0,0 @@
|
||||
import type { KcContextLike } from "@keycloakify/keycloak-account-ui";
|
||||
import type { KcEnvName } from "../kc.gen";
|
||||
|
||||
export type KcContext = KcContextLike & {
|
||||
themeType: "account";
|
||||
properties: Record<KcEnvName, string>;
|
||||
};
|
@ -1,11 +0,0 @@
|
||||
import { lazy } from "react";
|
||||
import { KcAccountUiLoader } from "@keycloakify/keycloak-account-ui";
|
||||
import type { KcContext } from "./KcContext";
|
||||
|
||||
const KcAccountUi = lazy(() => import("@keycloakify/keycloak-account-ui/KcAccountUi"));
|
||||
|
||||
export default function KcPage(props: { kcContext: KcContext }) {
|
||||
const { kcContext } = props;
|
||||
|
||||
return <KcAccountUiLoader kcContext={kcContext} KcAccountUi={KcAccountUi} />;
|
||||
}
|
@ -1,20 +1,13 @@
|
||||
import { dirname as pathDirname, join as pathJoin, relative as pathRelative } from "path";
|
||||
import type { BuildContext } from "./shared/buildContext";
|
||||
import * as fs from "fs";
|
||||
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
|
||||
import { assert, is, type Equals } from "tsafe/assert";
|
||||
import { id } from "tsafe/id";
|
||||
import { addSyncExtensionsToPostinstallScript } from "./shared/addSyncExtensionsToPostinstallScript";
|
||||
import { getIsPrettierAvailable, runPrettier } from "./tools/runPrettier";
|
||||
import { npmInstall } from "./tools/npmInstall";
|
||||
import * as child_process from "child_process";
|
||||
import { z } from "zod";
|
||||
import chalk from "chalk";
|
||||
import { initializeSpa } from "./shared/initializeSpa";
|
||||
import { exitIfUncommittedChanges } from "./shared/exitIfUncommittedChanges";
|
||||
import { command as updateKcGenCommand } from "./update-kc-gen";
|
||||
|
||||
export async function command(params: { buildContext: BuildContext }) {
|
||||
const { buildContext } = params;
|
||||
|
||||
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
|
||||
commandName: "initialize-admin-theme",
|
||||
buildContext
|
||||
});
|
||||
@ -23,124 +16,24 @@ export async function command(params: { buildContext: BuildContext }) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
const adminThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "admin");
|
||||
exitIfUncommittedChanges({
|
||||
projectDirPath: buildContext.projectDirPath
|
||||
});
|
||||
|
||||
if (
|
||||
fs.existsSync(adminThemeSrcDirPath) &&
|
||||
fs.readdirSync(adminThemeSrcDirPath).length > 0
|
||||
) {
|
||||
console.warn(
|
||||
chalk.red(
|
||||
`There is already a ${pathRelative(
|
||||
process.cwd(),
|
||||
adminThemeSrcDirPath
|
||||
)} directory in your project. Aborting.`
|
||||
)
|
||||
);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
const parsedPackageJson = (() => {
|
||||
type ParsedPackageJson = {
|
||||
scripts?: Record<string, string | undefined>;
|
||||
dependencies?: Record<string, string | undefined>;
|
||||
devDependencies?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
const zParsedPackageJson = (() => {
|
||||
type TargetType = ParsedPackageJson;
|
||||
|
||||
const zTargetType = z.object({
|
||||
scripts: z.record(z.union([z.string(), z.undefined()])).optional(),
|
||||
dependencies: z.record(z.union([z.string(), z.undefined()])).optional(),
|
||||
devDependencies: z.record(z.union([z.string(), z.undefined()])).optional()
|
||||
});
|
||||
|
||||
assert<Equals<z.infer<typeof zTargetType>, TargetType>>;
|
||||
|
||||
return id<z.ZodType<TargetType>>(zTargetType);
|
||||
})();
|
||||
const parsedPackageJson = JSON.parse(
|
||||
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
|
||||
);
|
||||
|
||||
zParsedPackageJson.parse(parsedPackageJson);
|
||||
|
||||
assert(is<ParsedPackageJson>(parsedPackageJson));
|
||||
|
||||
return parsedPackageJson;
|
||||
})();
|
||||
|
||||
addSyncExtensionsToPostinstallScript({
|
||||
parsedPackageJson,
|
||||
await initializeSpa({
|
||||
themeType: "admin",
|
||||
buildContext
|
||||
});
|
||||
|
||||
const uiSharedMajor = (() => {
|
||||
const dependencies = {
|
||||
...parsedPackageJson.devDependencies,
|
||||
...parsedPackageJson.dependencies
|
||||
};
|
||||
|
||||
const version = dependencies["@keycloakify/keycloak-ui-shared"];
|
||||
|
||||
if (version === undefined) {
|
||||
return undefined;
|
||||
await updateKcGenCommand({
|
||||
buildContext: {
|
||||
...buildContext,
|
||||
implementedThemeTypes: {
|
||||
...buildContext.implementedThemeTypes,
|
||||
admin: {
|
||||
isImplemented: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const match = version.match(/^[^~]?(\d+)\./);
|
||||
|
||||
if (match === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return match[1];
|
||||
})();
|
||||
|
||||
const moduleName = "@keycloakify/keycloak-admin-ui";
|
||||
|
||||
const version = (
|
||||
JSON.parse(
|
||||
child_process
|
||||
.execSync(`npm show ${moduleName} versions --json`)
|
||||
.toString("utf8")
|
||||
.trim()
|
||||
) as string[]
|
||||
)
|
||||
.reverse()
|
||||
.filter(version => !version.includes("-"))
|
||||
.find(version =>
|
||||
uiSharedMajor === undefined ? true : version.startsWith(`${uiSharedMajor}.`)
|
||||
);
|
||||
|
||||
assert(version !== undefined);
|
||||
|
||||
(parsedPackageJson.dependencies ??= {})[moduleName] = `~${version}`;
|
||||
|
||||
if (parsedPackageJson.devDependencies !== undefined) {
|
||||
delete parsedPackageJson.devDependencies[moduleName];
|
||||
}
|
||||
|
||||
{
|
||||
let sourceCode = JSON.stringify(parsedPackageJson, undefined, 2);
|
||||
|
||||
if (await getIsPrettierAvailable()) {
|
||||
sourceCode = await runPrettier({
|
||||
sourceCode,
|
||||
filePath: buildContext.packageJsonFilePath
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
buildContext.packageJsonFilePath,
|
||||
Buffer.from(sourceCode, "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
await npmInstall({
|
||||
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
|
||||
});
|
||||
}
|
||||
|
@ -1,18 +1,23 @@
|
||||
import { join as pathJoin, relative as pathRelative } from "path";
|
||||
import { transformCodebase } from "./tools/transformCodebase";
|
||||
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
|
||||
import type { BuildContext } from "./shared/buildContext";
|
||||
import * as fs from "fs";
|
||||
import { downloadAndExtractArchive } from "./tools/downloadAndExtractArchive";
|
||||
import cliSelect from "cli-select";
|
||||
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
|
||||
import fetch from "make-fetch-happen";
|
||||
import { SemVer } from "./tools/SemVer";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { exitIfUncommittedChanges } from "./shared/exitIfUncommittedChanges";
|
||||
|
||||
import { dirname as pathDirname, join as pathJoin, relative as pathRelative } from "path";
|
||||
import * as fs from "fs";
|
||||
import { assert, is, type Equals } from "tsafe/assert";
|
||||
import { id } from "tsafe/id";
|
||||
import { addSyncExtensionsToPostinstallScript } from "./shared/addSyncExtensionsToPostinstallScript";
|
||||
import { getIsPrettierAvailable, runPrettier } from "./tools/runPrettier";
|
||||
import { npmInstall } from "./tools/npmInstall";
|
||||
import * as child_process from "child_process";
|
||||
import { z } from "zod";
|
||||
import chalk from "chalk";
|
||||
|
||||
export async function command(params: { buildContext: BuildContext }) {
|
||||
const { buildContext } = params;
|
||||
|
||||
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
|
||||
commandName: "initialize-email-theme",
|
||||
buildContext
|
||||
});
|
||||
@ -21,6 +26,10 @@ export async function command(params: { buildContext: BuildContext }) {
|
||||
return;
|
||||
}
|
||||
|
||||
exitIfUncommittedChanges({
|
||||
projectDirPath: buildContext.projectDirPath
|
||||
});
|
||||
|
||||
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
|
||||
|
||||
if (
|
||||
@ -28,93 +37,120 @@ export async function command(params: { buildContext: BuildContext }) {
|
||||
fs.readdirSync(emailThemeSrcDirPath).length > 0
|
||||
) {
|
||||
console.warn(
|
||||
`There is already a non empty ${pathRelative(
|
||||
process.cwd(),
|
||||
emailThemeSrcDirPath
|
||||
)} directory in your project. Aborting.`
|
||||
chalk.red(
|
||||
`There is already a ${pathRelative(
|
||||
process.cwd(),
|
||||
emailThemeSrcDirPath
|
||||
)} directory in your project. Aborting.`
|
||||
)
|
||||
);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
console.log("Initialize with the base email theme from which version of Keycloak?");
|
||||
const { value: emailThemeType } = await cliSelect({
|
||||
values: [
|
||||
"native (FreeMarker)" as const,
|
||||
"Another email templating solution" as const
|
||||
]
|
||||
}).catch(() => {
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
let { keycloakVersion } = await promptKeycloakVersion({
|
||||
// NOTE: This is arbitrary
|
||||
startingFromMajor: 17,
|
||||
excludeMajorVersions: [],
|
||||
doOmitPatch: false,
|
||||
if (emailThemeType === "Another email templating solution") {
|
||||
console.log(
|
||||
[
|
||||
"There is currently no automated support for keycloakify-email, it has to be done manually, see documentation:",
|
||||
"https://docs.keycloakify.dev/theme-types/email-theme"
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const parsedPackageJson = (() => {
|
||||
type ParsedPackageJson = {
|
||||
scripts?: Record<string, string | undefined>;
|
||||
dependencies?: Record<string, string | undefined>;
|
||||
devDependencies?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
const zParsedPackageJson = (() => {
|
||||
type TargetType = ParsedPackageJson;
|
||||
|
||||
const zTargetType = z.object({
|
||||
scripts: z.record(z.union([z.string(), z.undefined()])).optional(),
|
||||
dependencies: z.record(z.union([z.string(), z.undefined()])).optional(),
|
||||
devDependencies: z.record(z.union([z.string(), z.undefined()])).optional()
|
||||
});
|
||||
|
||||
assert<Equals<z.infer<typeof zTargetType>, TargetType>>;
|
||||
|
||||
return id<z.ZodType<TargetType>>(zTargetType);
|
||||
})();
|
||||
const parsedPackageJson = JSON.parse(
|
||||
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
|
||||
);
|
||||
|
||||
zParsedPackageJson.parse(parsedPackageJson);
|
||||
|
||||
assert(is<ParsedPackageJson>(parsedPackageJson));
|
||||
|
||||
return parsedPackageJson;
|
||||
})();
|
||||
|
||||
addSyncExtensionsToPostinstallScript({
|
||||
parsedPackageJson,
|
||||
buildContext
|
||||
});
|
||||
|
||||
const getUrl = (keycloakVersion: string) => {
|
||||
return `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`;
|
||||
};
|
||||
const moduleName = `@keycloakify/email-native`;
|
||||
|
||||
keycloakVersion = await (async () => {
|
||||
const keycloakVersionParsed = SemVer.parse(keycloakVersion);
|
||||
const [version] = ((): string[] => {
|
||||
const cmdOutput = child_process
|
||||
.execSync(`npm show ${moduleName} versions --json`)
|
||||
.toString("utf8")
|
||||
.trim();
|
||||
|
||||
while (true) {
|
||||
const url = getUrl(SemVer.stringify(keycloakVersionParsed));
|
||||
const versions = JSON.parse(cmdOutput) as string | string[];
|
||||
|
||||
const response = await fetch(url, buildContext.fetchOptions);
|
||||
|
||||
if (response.ok) {
|
||||
break;
|
||||
}
|
||||
|
||||
assert(keycloakVersionParsed.patch !== 0);
|
||||
|
||||
keycloakVersionParsed.patch--;
|
||||
// NOTE: Bug in some older npm versions
|
||||
if (typeof versions === "string") {
|
||||
return [versions];
|
||||
}
|
||||
|
||||
return SemVer.stringify(keycloakVersionParsed);
|
||||
})();
|
||||
return versions;
|
||||
})()
|
||||
.reverse()
|
||||
.filter(version => !version.includes("-"));
|
||||
|
||||
const { extractedDirPath } = await downloadAndExtractArchive({
|
||||
url: getUrl(keycloakVersion),
|
||||
cacheDirPath: buildContext.cacheDirPath,
|
||||
fetchOptions: buildContext.fetchOptions,
|
||||
uniqueIdOfOnArchiveFile: "extractOnlyEmailTheme",
|
||||
onArchiveFile: async ({ fileRelativePath, writeFile }) => {
|
||||
const fileRelativePath_target = pathRelative(
|
||||
pathJoin("theme", "base", "email"),
|
||||
fileRelativePath
|
||||
);
|
||||
assert(version !== undefined);
|
||||
|
||||
if (fileRelativePath_target.startsWith("..")) {
|
||||
return;
|
||||
}
|
||||
(parsedPackageJson.dependencies ??= {})[moduleName] = `~${version}`;
|
||||
|
||||
await writeFile({ fileRelativePath: fileRelativePath_target });
|
||||
}
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: extractedDirPath,
|
||||
destDirPath: emailThemeSrcDirPath
|
||||
});
|
||||
if (parsedPackageJson.devDependencies !== undefined) {
|
||||
delete parsedPackageJson.devDependencies[moduleName];
|
||||
}
|
||||
|
||||
{
|
||||
const themePropertyFilePath = pathJoin(emailThemeSrcDirPath, "theme.properties");
|
||||
let sourceCode = JSON.stringify(parsedPackageJson, undefined, 2);
|
||||
|
||||
if (await getIsPrettierAvailable()) {
|
||||
sourceCode = await runPrettier({
|
||||
sourceCode,
|
||||
filePath: buildContext.packageJsonFilePath
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
themePropertyFilePath,
|
||||
Buffer.from(
|
||||
[
|
||||
`parent=base`,
|
||||
fs.readFileSync(themePropertyFilePath).toString("utf8")
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
)
|
||||
buildContext.packageJsonFilePath,
|
||||
Buffer.from(sourceCode, "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`The \`${pathJoin(
|
||||
".",
|
||||
pathRelative(process.cwd(), emailThemeSrcDirPath)
|
||||
)}\` directory have been created.`
|
||||
);
|
||||
console.log("You can delete any file you don't modify.");
|
||||
await npmInstall({
|
||||
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
|
||||
});
|
||||
|
||||
console.log(chalk.green("Email theme initialized."));
|
||||
}
|
||||
|
@ -15,7 +15,6 @@ import { readFileSync } from "fs";
|
||||
import { isInside } from "../../tools/isInside";
|
||||
import child_process from "child_process";
|
||||
import { rmSync } from "../../tools/fs.rmSync";
|
||||
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
|
||||
import { existsAsync } from "../../tools/fs.existsAsync";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_generatePom & {
|
||||
@ -106,29 +105,55 @@ export async function buildJar(params: {
|
||||
}
|
||||
});
|
||||
|
||||
remove_account_v1_in_meta_inf: {
|
||||
if (!doesImplementAccountV1Theme) {
|
||||
// NOTE: We do not have account v1 anyway
|
||||
break remove_account_v1_in_meta_inf;
|
||||
}
|
||||
{
|
||||
const filePath = pathJoin(
|
||||
tmpResourcesDirPath,
|
||||
"META-INF",
|
||||
"keycloak-themes.json"
|
||||
);
|
||||
|
||||
if (keycloakAccountV1Version !== null) {
|
||||
// NOTE: No, we need to keep account-v1 in meta-inf
|
||||
break remove_account_v1_in_meta_inf;
|
||||
}
|
||||
await fs.mkdir(pathDirname(filePath));
|
||||
|
||||
writeMetaInfKeycloakThemes({
|
||||
resourcesDirPath: tmpResourcesDirPath,
|
||||
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => {
|
||||
assert(metaInfKeycloakTheme !== undefined);
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
Buffer.from(
|
||||
JSON.stringify(
|
||||
{
|
||||
themes: await (async () => {
|
||||
const dirPath = pathJoin(tmpResourcesDirPath, "theme");
|
||||
|
||||
metaInfKeycloakTheme.themes = metaInfKeycloakTheme.themes.filter(
|
||||
({ name }) => name !== "account-v1"
|
||||
);
|
||||
const themeNames = (await fs.readdir(dirPath)).sort(
|
||||
(a, b) => {
|
||||
const indexA = buildContext.themeNames.indexOf(a);
|
||||
const indexB = buildContext.themeNames.indexOf(b);
|
||||
|
||||
return metaInfKeycloakTheme;
|
||||
}
|
||||
});
|
||||
const orderA = indexA === -1 ? Infinity : indexA;
|
||||
const orderB = indexB === -1 ? Infinity : indexB;
|
||||
|
||||
return orderA - orderB;
|
||||
}
|
||||
);
|
||||
|
||||
return Promise.all(
|
||||
themeNames.map(async themeName => {
|
||||
const types = await fs.readdir(
|
||||
pathJoin(dirPath, themeName)
|
||||
);
|
||||
|
||||
return {
|
||||
name: themeName,
|
||||
types
|
||||
};
|
||||
})
|
||||
);
|
||||
})()
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
route_legacy_pages: {
|
||||
@ -195,31 +220,39 @@ export async function buildJar(params: {
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
child_process.exec(
|
||||
`mvn clean install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`,
|
||||
{ cwd: keycloakifyBuildCacheDirPath },
|
||||
error => {
|
||||
if (error !== null) {
|
||||
console.error(
|
||||
`Build jar failed: ${JSON.stringify(
|
||||
{
|
||||
jarFileBasename,
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
{
|
||||
const mvnBuildCmd = `mvn clean install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`;
|
||||
|
||||
reject(error);
|
||||
return;
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
child_process.exec(
|
||||
mvnBuildCmd,
|
||||
{ cwd: keycloakifyBuildCacheDirPath },
|
||||
error => {
|
||||
if (error !== null) {
|
||||
console.error(
|
||||
[
|
||||
`Build jar failed: ${JSON.stringify(
|
||||
{
|
||||
jarFileBasename,
|
||||
keycloakAccountV1Version,
|
||||
keycloakThemeAdditionalInfoExtensionVersion
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`,
|
||||
"Try running the following command to debug the issue (you are probably under a restricted network and you need to configure your proxy):",
|
||||
`cd ${keycloakifyBuildCacheDirPath} && ${mvnBuildCmd}`
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
)
|
||||
);
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await fs.rename(
|
||||
pathJoin(
|
||||
|
@ -190,7 +190,7 @@ function decodeHtmlEntities(htmlStr){
|
||||
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
|
||||
key == "loginAction" &&
|
||||
areSamePath(path, ["url"]) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(xKeycloakify.pageId) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl", "frontchannel-logout.ftl"]?seq_contains(xKeycloakify.pageId) &&
|
||||
!(auth?has_content && auth.showTryAnotherWayLink())
|
||||
) || (
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
|
||||
|
@ -6,8 +6,7 @@ import {
|
||||
join as pathJoin,
|
||||
relative as pathRelative,
|
||||
dirname as pathDirname,
|
||||
extname as pathExtname,
|
||||
sep as pathSep
|
||||
basename as pathBasename
|
||||
} from "path";
|
||||
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
|
||||
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
|
||||
@ -31,16 +30,13 @@ import {
|
||||
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";
|
||||
import { createObjectThatThrowsIfAccessed } from "../../tools/createObjectThatThrowsIfAccessed";
|
||||
import { listInstalledModules } from "../../tools/listInstalledModules";
|
||||
import { isInside } from "../../tools/isInside";
|
||||
import { id } from "tsafe/id";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
|
||||
BuildContextLike_generateMessageProperties & {
|
||||
@ -61,6 +57,8 @@ export async function generateResources(params: {
|
||||
buildContext: BuildContextLike;
|
||||
resourcesDirPath: string;
|
||||
}): Promise<void> {
|
||||
const start = Date.now();
|
||||
|
||||
const { resourcesDirPath, buildContext } = params;
|
||||
|
||||
const [themeName] = buildContext.themeNames;
|
||||
@ -78,12 +76,23 @@ export async function generateResources(params: {
|
||||
};
|
||||
|
||||
const writeMessagePropertiesFilesByThemeType: Partial<
|
||||
Record<ThemeType, (params: { messageDirPath: string; themeName: string }) => void>
|
||||
Record<
|
||||
ThemeType | "email",
|
||||
(params: { messageDirPath: string; themeName: string }) => void
|
||||
>
|
||||
> = {};
|
||||
|
||||
for (const themeType of THEME_TYPES) {
|
||||
if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
|
||||
continue;
|
||||
for (const themeType of [...THEME_TYPES, "email"] as const) {
|
||||
let isNative: boolean;
|
||||
|
||||
{
|
||||
const v = buildContext.implementedThemeTypes[themeType];
|
||||
|
||||
if (!v.isImplemented && !v.isImplemented_native) {
|
||||
continue;
|
||||
}
|
||||
|
||||
isNative = !v.isImplemented && v.isImplemented_native;
|
||||
}
|
||||
|
||||
const getAccountThemeType = () => {
|
||||
@ -102,12 +111,18 @@ export async function generateResources(params: {
|
||||
return getAccountThemeType() === "Single-Page";
|
||||
case "admin":
|
||||
return true;
|
||||
case "email":
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const themeTypeDirPath = getThemeTypeDirPath({ themeName, themeType });
|
||||
|
||||
apply_replacers_and_move_to_theme_resources: {
|
||||
if (isNative) {
|
||||
break apply_replacers_and_move_to_theme_resources;
|
||||
}
|
||||
|
||||
const destDirPath = pathJoin(
|
||||
themeTypeDirPath,
|
||||
"resources",
|
||||
@ -191,59 +206,93 @@ export async function generateResources(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
|
||||
themeName,
|
||||
indexHtmlCode: fs
|
||||
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
|
||||
.toString("utf8"),
|
||||
buildContext,
|
||||
keycloakifyVersion: readThisNpmPackageVersion(),
|
||||
themeType,
|
||||
fieldNames: isSpa
|
||||
? []
|
||||
: (assert(themeType !== "admin"),
|
||||
readFieldNameUsage({
|
||||
themeSrcDirPath: buildContext.themeSrcDirPath,
|
||||
themeType
|
||||
}))
|
||||
});
|
||||
generate_ftl_files: {
|
||||
if (isNative) {
|
||||
break generate_ftl_files;
|
||||
}
|
||||
|
||||
[
|
||||
...(() => {
|
||||
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"];
|
||||
assert(themeType !== "email");
|
||||
|
||||
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
|
||||
themeName,
|
||||
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")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
copy_native_theme: {
|
||||
if (!isNative) {
|
||||
break copy_native_theme;
|
||||
}
|
||||
|
||||
const dirPath = pathJoin(buildContext.themeSrcDirPath, themeType);
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: dirPath,
|
||||
destDirPath: getThemeTypeDirPath({ themeName, themeType }),
|
||||
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
|
||||
if (isInside({ dirPath: "messages", filePath: fileRelativePath })) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
})(),
|
||||
...(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;
|
||||
i18n_multi_page: {
|
||||
if (isNative) {
|
||||
break i18n_multi_page;
|
||||
}
|
||||
|
||||
assert(themeType !== "admin");
|
||||
if (isSpa) {
|
||||
break i18n_multi_page;
|
||||
}
|
||||
|
||||
assert(themeType !== "admin" && themeType !== "email");
|
||||
|
||||
const wrap = generateMessageProperties({
|
||||
buildContext,
|
||||
@ -257,23 +306,43 @@ export async function generateResources(params: {
|
||||
writeMessagePropertiesFiles;
|
||||
}
|
||||
|
||||
bring_in_account_spa_messages: {
|
||||
let isLegacyAccountSpa = false;
|
||||
|
||||
// NOTE: Eventually remove this block.
|
||||
i18n_single_page_account_legacy: {
|
||||
if (!isSpa) {
|
||||
break bring_in_account_spa_messages;
|
||||
break i18n_single_page_account_legacy;
|
||||
}
|
||||
|
||||
if (themeType !== "account") {
|
||||
break bring_in_account_spa_messages;
|
||||
break i18n_single_page_account_legacy;
|
||||
}
|
||||
|
||||
const accountUiDirPath = child_process
|
||||
.execSync(`npm list @keycloakify/keycloak-account-ui --parseable`, {
|
||||
cwd: pathDirname(buildContext.packageJsonFilePath)
|
||||
})
|
||||
.toString("utf8")
|
||||
.trim();
|
||||
const [moduleMeta] = await listInstalledModules({
|
||||
packageJsonFilePath: buildContext.packageJsonFilePath,
|
||||
filter: ({ moduleName }) =>
|
||||
moduleName === "@keycloakify/keycloak-account-ui"
|
||||
});
|
||||
|
||||
const messageDirPath_defaults = pathJoin(accountUiDirPath, "messages");
|
||||
assert(
|
||||
moduleMeta !== undefined,
|
||||
`@keycloakify/keycloak-account-ui is supposed to be installed`
|
||||
);
|
||||
|
||||
{
|
||||
const [majorStr] = moduleMeta.version.split(".");
|
||||
|
||||
if (majorStr.length === 6) {
|
||||
// NOTE: Now we use the format MMmmpp (Major, minor, patch) for example for
|
||||
// 26.0.7 it would be 260007.
|
||||
break i18n_single_page_account_legacy;
|
||||
} else {
|
||||
// 25.0.4-rc.5 or later
|
||||
isLegacyAccountSpa = true;
|
||||
}
|
||||
}
|
||||
|
||||
const messageDirPath_defaults = pathJoin(moduleMeta.dirPath, "messages");
|
||||
|
||||
if (!fs.existsSync(messageDirPath_defaults)) {
|
||||
throw new Error(
|
||||
@ -281,6 +350,8 @@ export async function generateResources(params: {
|
||||
);
|
||||
}
|
||||
|
||||
isLegacyAccountSpa = true;
|
||||
|
||||
const messagesDirPath_dest = pathJoin(
|
||||
getThemeTypeDirPath({ themeName, themeType: "account" }),
|
||||
"messages"
|
||||
@ -342,21 +413,24 @@ export async function generateResources(params: {
|
||||
);
|
||||
}
|
||||
|
||||
bring_in_admin_messages: {
|
||||
if (themeType !== "admin") {
|
||||
break bring_in_admin_messages;
|
||||
i18n_for_spas_and_native: {
|
||||
if (!isSpa && !isNative) {
|
||||
break i18n_for_spas_and_native;
|
||||
}
|
||||
|
||||
if (isLegacyAccountSpa) {
|
||||
break i18n_for_spas_and_native;
|
||||
}
|
||||
|
||||
const messagesDirPath_theme = pathJoin(
|
||||
buildContext.themeSrcDirPath,
|
||||
"admin",
|
||||
"i18n"
|
||||
themeType,
|
||||
isNative ? "messages" : "i18n"
|
||||
);
|
||||
|
||||
assert(
|
||||
fs.existsSync(messagesDirPath_theme),
|
||||
`${messagesDirPath_theme} is supposed to exist`
|
||||
);
|
||||
if (!fs.existsSync(messagesDirPath_theme)) {
|
||||
break i18n_for_spas_and_native;
|
||||
}
|
||||
|
||||
const propertiesByLang: Record<
|
||||
string,
|
||||
@ -423,7 +497,7 @@ export async function generateResources(params: {
|
||||
|
||||
propertiesByLang[parsedBasename.lang] ??= {
|
||||
base: createObjectThatThrowsIfAccessed<Buffer>({
|
||||
debugMessage: `No base ${parsedBasename.lang} translation for admin theme`
|
||||
debugMessage: `No base ${parsedBasename.lang} translation for ${themeType} theme`
|
||||
}),
|
||||
override: undefined,
|
||||
overrideByThemeName: {}
|
||||
@ -446,7 +520,9 @@ export async function generateResources(params: {
|
||||
] = buffer;
|
||||
});
|
||||
|
||||
writeMessagePropertiesFilesByThemeType.admin = ({
|
||||
languageTags = Object.keys(propertiesByLang);
|
||||
|
||||
writeMessagePropertiesFilesByThemeType[themeType] = ({
|
||||
messageDirPath,
|
||||
themeName
|
||||
}) => {
|
||||
@ -456,8 +532,6 @@ export async function generateResources(params: {
|
||||
|
||||
Object.entries(propertiesByLang).forEach(
|
||||
([lang, { base, override, overrideByThemeName }]) => {
|
||||
(languageTags ??= []).push(lang);
|
||||
|
||||
const messages = propertiesParser.parse(base.toString("utf8"));
|
||||
|
||||
if (override !== undefined) {
|
||||
@ -496,6 +570,10 @@ export async function generateResources(params: {
|
||||
}
|
||||
|
||||
keycloak_static_resources: {
|
||||
if (isNative) {
|
||||
break keycloak_static_resources;
|
||||
}
|
||||
|
||||
if (isSpa) {
|
||||
break keycloak_static_resources;
|
||||
}
|
||||
@ -512,183 +590,167 @@ export async function generateResources(params: {
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
{ name: KEYCLOAKIFY_SPA_DEV_SERVER_PORT, default: "" }
|
||||
].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 };
|
||||
bring_in_account_v1: {
|
||||
if (isNative) {
|
||||
break bring_in_account_v1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
if (themeType !== "account") {
|
||||
break bring_in_account_v1;
|
||||
}
|
||||
writeMessagePropertiesFiles({
|
||||
messageDirPath: pathJoin(
|
||||
getThemeTypeDirPath({ themeName, themeType }),
|
||||
"messages"
|
||||
),
|
||||
themeName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
modify_email_theme_per_variant: {
|
||||
if (!buildContext.implementedThemeTypes.email.isImplemented) {
|
||||
break modify_email_theme_per_variant;
|
||||
}
|
||||
assert(buildContext.implementedThemeTypes.account.isImplemented);
|
||||
|
||||
for (const themeName of buildContext.themeNames) {
|
||||
const emailThemeDirPath = getThemeTypeDirPath({
|
||||
themeName,
|
||||
themeType: "email"
|
||||
});
|
||||
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
|
||||
break bring_in_account_v1;
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
};
|
||||
}
|
||||
srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
|
||||
destDirPath: getThemeTypeDirPath({
|
||||
themeName: "account-v1",
|
||||
themeType: "account"
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
generate_theme_properties: {
|
||||
if (isNative) {
|
||||
break generate_theme_properties;
|
||||
}
|
||||
|
||||
assert(themeType !== "email");
|
||||
|
||||
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>>;
|
||||
})()}`,
|
||||
...(themeType === "account" &&
|
||||
getAccountThemeType() === "Single-Page"
|
||||
? ["deprecatedMode=false"]
|
||||
: []),
|
||||
...(buildContext.extraThemeProperties ?? []),
|
||||
...[
|
||||
...buildContext.environmentVariables,
|
||||
{ name: KEYCLOAKIFY_SPA_DEV_SERVER_PORT, default: "" }
|
||||
].map(
|
||||
({ name, default: defaultValue }) =>
|
||||
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
|
||||
),
|
||||
...(languageTags === undefined
|
||||
? []
|
||||
: [`locales=${languageTags.join(",")}`])
|
||||
].join("\n\n"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const themeVariantName of [...buildContext.themeNames].reverse()) {
|
||||
for (const themeType of [...THEME_TYPES, "email"] as const) {
|
||||
copy_main_theme_to_theme_variant_theme: {
|
||||
let isNative: boolean;
|
||||
|
||||
{
|
||||
const v = buildContext.implementedThemeTypes[themeType];
|
||||
|
||||
if (!v.isImplemented && !v.isImplemented_native) {
|
||||
break copy_main_theme_to_theme_variant_theme;
|
||||
}
|
||||
|
||||
isNative = !v.isImplemented && v.isImplemented_native;
|
||||
}
|
||||
|
||||
if (!isNative && themeVariantName === themeName) {
|
||||
break copy_main_theme_to_theme_variant_theme;
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: getThemeTypeDirPath({ themeName, themeType }),
|
||||
destDirPath: getThemeTypeDirPath({
|
||||
themeName: themeVariantName,
|
||||
themeType
|
||||
}),
|
||||
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
|
||||
patch_xKeycloakify_themeName: {
|
||||
if (!fileRelativePath.endsWith(".ftl")) {
|
||||
break patch_xKeycloakify_themeName;
|
||||
}
|
||||
|
||||
if (
|
||||
!isNative &&
|
||||
pathBasename(fileRelativePath) !== fileRelativePath
|
||||
) {
|
||||
break patch_xKeycloakify_themeName;
|
||||
}
|
||||
|
||||
const modifiedSourceCode = Buffer.from(
|
||||
Buffer.from(sourceCode)
|
||||
.toString("utf-8")
|
||||
.replace(
|
||||
...id<[string | RegExp, string]>(
|
||||
isNative
|
||||
? [
|
||||
/xKeycloakify\.themeName/g,
|
||||
`"${themeVariantName}"`
|
||||
]
|
||||
: [
|
||||
`"themeName": "${themeName}"`,
|
||||
`"themeName": "${themeVariantName}"`
|
||||
]
|
||||
)
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
if (!isNative) {
|
||||
assert(
|
||||
Buffer.compare(modifiedSourceCode, sourceCode) !== 0
|
||||
);
|
||||
}
|
||||
|
||||
return { modifiedSourceCode };
|
||||
}
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
});
|
||||
}
|
||||
run_writeMessagePropertiesFiles: {
|
||||
const writeMessagePropertiesFiles =
|
||||
writeMessagePropertiesFilesByThemeType[themeType];
|
||||
|
||||
if (writeMessagePropertiesFiles === undefined) {
|
||||
break run_writeMessagePropertiesFiles;
|
||||
}
|
||||
|
||||
writeMessagePropertiesFiles({
|
||||
messageDirPath: pathJoin(
|
||||
getThemeTypeDirPath({ themeName: themeVariantName, themeType }),
|
||||
"messages"
|
||||
),
|
||||
themeName: themeVariantName
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Generated resources in ${Date.now() - start}ms`);
|
||||
}
|
||||
|
@ -303,7 +303,7 @@ program
|
||||
key: "path",
|
||||
name: (() => {
|
||||
const long = "path";
|
||||
const short = "p";
|
||||
const short = "t";
|
||||
|
||||
optionsKeys.push(long, short);
|
||||
|
||||
@ -318,11 +318,12 @@ program
|
||||
.option({
|
||||
key: "revert",
|
||||
name: (() => {
|
||||
const name = "revert";
|
||||
const long = "revert";
|
||||
const short = "r";
|
||||
|
||||
optionsKeys.push(name);
|
||||
optionsKeys.push(long, short);
|
||||
|
||||
return name;
|
||||
return { long, short };
|
||||
})(),
|
||||
description: [
|
||||
"Restores a file or directory to its original auto-generated state,",
|
||||
|
@ -124,8 +124,7 @@ async function command_own(params: Params_subcommands) {
|
||||
] of targetFileRelativePathsByExtensionModuleMeta.entries()) {
|
||||
const extensionModuleDirPath = await getInstalledModuleDirPath({
|
||||
moduleName: extensionModuleMeta.moduleName,
|
||||
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath),
|
||||
projectDirPath: buildContext.projectDirPath
|
||||
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
|
||||
});
|
||||
|
||||
for (const fileRelativePath of fileRelativePaths) {
|
||||
|
@ -56,7 +56,7 @@ export function addSyncExtensionsToPostinstallScript(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cmd_preexisting.includes(cmd_base)) {
|
||||
if (!cmd_preexisting.includes(cmd_base)) {
|
||||
scripts[scriptName] = generateCmd({ cmd_preexisting });
|
||||
return;
|
||||
}
|
||||
|
@ -45,12 +45,16 @@ export type BuildContext = {
|
||||
environmentVariables: { name: string; default: string }[];
|
||||
themeSrcDirPath: string;
|
||||
implementedThemeTypes: {
|
||||
login: { isImplemented: boolean };
|
||||
email: { isImplemented: boolean };
|
||||
login:
|
||||
| { isImplemented: true }
|
||||
| { isImplemented: false; isImplemented_native: boolean };
|
||||
email: { isImplemented: false; isImplemented_native: boolean };
|
||||
account:
|
||||
| { isImplemented: false }
|
||||
| { isImplemented: false; isImplemented_native: boolean }
|
||||
| { isImplemented: true; type: "Single-Page" | "Multi-Page" };
|
||||
admin: { isImplemented: boolean };
|
||||
admin:
|
||||
| { isImplemented: true }
|
||||
| { isImplemented: false; isImplemented_native: boolean };
|
||||
};
|
||||
packageJsonFilePath: string;
|
||||
bundler: "vite" | "webpack";
|
||||
@ -434,27 +438,68 @@ export function getBuildContext(params: {
|
||||
assert<Equals<typeof bundler, never>>(false);
|
||||
})();
|
||||
|
||||
const implementedThemeTypes: BuildContext["implementedThemeTypes"] = {
|
||||
login: {
|
||||
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "login"))
|
||||
},
|
||||
email: {
|
||||
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "email"))
|
||||
},
|
||||
account: (() => {
|
||||
if (buildOptions.accountThemeImplementation === "none") {
|
||||
return { isImplemented: false };
|
||||
}
|
||||
const implementedThemeTypes: BuildContext["implementedThemeTypes"] = (() => {
|
||||
const getIsNative = (dirPath: string) =>
|
||||
fs.existsSync(pathJoin(dirPath, "theme.properties"));
|
||||
|
||||
return {
|
||||
isImplemented: true,
|
||||
type: buildOptions.accountThemeImplementation
|
||||
};
|
||||
})(),
|
||||
admin: {
|
||||
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "admin"))
|
||||
}
|
||||
};
|
||||
return {
|
||||
login: (() => {
|
||||
const dirPath = pathJoin(themeSrcDirPath, "login");
|
||||
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return { isImplemented: false, isImplemented_native: false };
|
||||
}
|
||||
|
||||
if (getIsNative(dirPath)) {
|
||||
return { isImplemented: false, isImplemented_native: true };
|
||||
}
|
||||
|
||||
return { isImplemented: true };
|
||||
})(),
|
||||
email: (() => {
|
||||
const dirPath = pathJoin(themeSrcDirPath, "email");
|
||||
|
||||
if (!fs.existsSync(dirPath) || !getIsNative(dirPath)) {
|
||||
return { isImplemented: false, isImplemented_native: false };
|
||||
}
|
||||
|
||||
return { isImplemented: false, isImplemented_native: true };
|
||||
})(),
|
||||
account: (() => {
|
||||
const dirPath = pathJoin(themeSrcDirPath, "account");
|
||||
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return { isImplemented: false, isImplemented_native: false };
|
||||
}
|
||||
|
||||
if (getIsNative(dirPath)) {
|
||||
return { isImplemented: false, isImplemented_native: true };
|
||||
}
|
||||
|
||||
if (buildOptions.accountThemeImplementation === "none") {
|
||||
return { isImplemented: false, isImplemented_native: false };
|
||||
}
|
||||
|
||||
return {
|
||||
isImplemented: true,
|
||||
type: buildOptions.accountThemeImplementation
|
||||
};
|
||||
})(),
|
||||
admin: (() => {
|
||||
const dirPath = pathJoin(themeSrcDirPath, "admin");
|
||||
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return { isImplemented: false, isImplemented_native: false };
|
||||
}
|
||||
|
||||
if (getIsNative(dirPath)) {
|
||||
return { isImplemented: false, isImplemented_native: true };
|
||||
}
|
||||
|
||||
return { isImplemented: true };
|
||||
})()
|
||||
};
|
||||
})();
|
||||
|
||||
if (
|
||||
implementedThemeTypes.account.isImplemented &&
|
||||
|
@ -13,13 +13,15 @@ import * as fs from "fs";
|
||||
|
||||
assert<Equals<ApiVersion, "v1">>();
|
||||
|
||||
export function maybeDelegateCommandToCustomHandler(params: {
|
||||
export async function maybeDelegateCommandToCustomHandler(params: {
|
||||
commandName: CommandName;
|
||||
buildContext: BuildContext;
|
||||
}): { hasBeenHandled: boolean } {
|
||||
}): Promise<{ hasBeenHandled: boolean }> {
|
||||
const { commandName, buildContext } = params;
|
||||
|
||||
const nodeModulesBinDirPath = getNodeModulesBinDirPath();
|
||||
const nodeModulesBinDirPath = await getNodeModulesBinDirPath({
|
||||
packageJsonFilePath: buildContext.packageJsonFilePath
|
||||
});
|
||||
|
||||
if (!fs.readdirSync(nodeModulesBinDirPath).includes(BIN_NAME)) {
|
||||
return { hasBeenHandled: false };
|
||||
|
@ -1,201 +0,0 @@
|
||||
import { getLatestsSemVersionedTagFactory } from "../tools/octokit-addons/getLatestsSemVersionedTag";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { ReturnType } from "tsafe";
|
||||
import type { Param0 } from "tsafe";
|
||||
import { join as pathJoin, dirname as pathDirname } from "path";
|
||||
import * as fs from "fs";
|
||||
import { z } from "zod";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import { id } from "tsafe/id";
|
||||
import type { SemVer } from "../tools/SemVer";
|
||||
import { same } from "evt/tools/inDepth/same";
|
||||
import type { BuildContext } from "./buildContext";
|
||||
import fetch from "make-fetch-happen";
|
||||
|
||||
type GetLatestsSemVersionedTag = ReturnType<
|
||||
typeof getLatestsSemVersionedTagFactory
|
||||
>["getLatestsSemVersionedTag"];
|
||||
|
||||
type Params = Param0<GetLatestsSemVersionedTag>;
|
||||
type R = ReturnType<GetLatestsSemVersionedTag>;
|
||||
|
||||
let getLatestsSemVersionedTag_stateless: GetLatestsSemVersionedTag | undefined =
|
||||
undefined;
|
||||
|
||||
const CACHE_VERSION = 1;
|
||||
|
||||
type Cache = {
|
||||
version: typeof CACHE_VERSION;
|
||||
entries: {
|
||||
time: number;
|
||||
params: Params;
|
||||
result: R;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type BuildContextLike = {
|
||||
cacheDirPath: string;
|
||||
fetchOptions: BuildContext["fetchOptions"];
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export async function getLatestsSemVersionedTag({
|
||||
buildContext,
|
||||
...params
|
||||
}: Params & {
|
||||
buildContext: BuildContextLike;
|
||||
}): Promise<R> {
|
||||
const cacheFilePath = pathJoin(
|
||||
buildContext.cacheDirPath,
|
||||
"latest-sem-versioned-tags.json"
|
||||
);
|
||||
|
||||
const cacheLookupResult = (() => {
|
||||
const getResult_currentCache = (currentCacheEntries: Cache["entries"]) => ({
|
||||
hasCachedResult: false as const,
|
||||
currentCache: {
|
||||
version: CACHE_VERSION,
|
||||
entries: currentCacheEntries
|
||||
}
|
||||
});
|
||||
|
||||
if (!fs.existsSync(cacheFilePath)) {
|
||||
return getResult_currentCache([]);
|
||||
}
|
||||
|
||||
let cache_json;
|
||||
|
||||
try {
|
||||
cache_json = fs.readFileSync(cacheFilePath).toString("utf8");
|
||||
} catch {
|
||||
return getResult_currentCache([]);
|
||||
}
|
||||
|
||||
let cache_json_parsed: unknown;
|
||||
|
||||
try {
|
||||
cache_json_parsed = JSON.parse(cache_json);
|
||||
} catch {
|
||||
return getResult_currentCache([]);
|
||||
}
|
||||
|
||||
const zSemVer = (() => {
|
||||
type TargetType = SemVer;
|
||||
|
||||
const zTargetType = z.object({
|
||||
major: z.number(),
|
||||
minor: z.number(),
|
||||
patch: z.number(),
|
||||
rc: z.number().optional(),
|
||||
parsedFrom: z.string()
|
||||
});
|
||||
|
||||
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
|
||||
|
||||
return id<z.ZodType<TargetType>>(zTargetType);
|
||||
})();
|
||||
|
||||
const zCache = (() => {
|
||||
type TargetType = Cache;
|
||||
|
||||
const zTargetType = z.object({
|
||||
version: z.literal(CACHE_VERSION),
|
||||
entries: z.array(
|
||||
z.object({
|
||||
time: z.number(),
|
||||
params: z.object({
|
||||
owner: z.string(),
|
||||
repo: z.string(),
|
||||
count: z.number(),
|
||||
doIgnoreReleaseCandidates: z.boolean()
|
||||
}),
|
||||
result: z.array(
|
||||
z.object({
|
||||
tag: z.string(),
|
||||
version: zSemVer
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
|
||||
|
||||
return id<z.ZodType<TargetType>>(zTargetType);
|
||||
})();
|
||||
|
||||
let cache: Cache;
|
||||
|
||||
try {
|
||||
cache = zCache.parse(cache_json_parsed);
|
||||
} catch {
|
||||
return getResult_currentCache([]);
|
||||
}
|
||||
|
||||
const cacheEntry = cache.entries.find(e => same(e.params, params));
|
||||
|
||||
if (cacheEntry === undefined) {
|
||||
return getResult_currentCache(cache.entries);
|
||||
}
|
||||
|
||||
if (Date.now() - cacheEntry.time > 3_600_000) {
|
||||
return getResult_currentCache(cache.entries.filter(e => e !== cacheEntry));
|
||||
}
|
||||
return {
|
||||
hasCachedResult: true as const,
|
||||
cachedResult: cacheEntry.result
|
||||
};
|
||||
})();
|
||||
|
||||
if (cacheLookupResult.hasCachedResult) {
|
||||
return cacheLookupResult.cachedResult;
|
||||
}
|
||||
|
||||
const { currentCache } = cacheLookupResult;
|
||||
|
||||
getLatestsSemVersionedTag_stateless ??= (() => {
|
||||
const octokit = (() => {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
|
||||
const octokit = new Octokit({
|
||||
...(githubToken === undefined ? {} : { auth: githubToken }),
|
||||
request: {
|
||||
fetch: (url: string, options?: any) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
...buildContext.fetchOptions
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
return octokit;
|
||||
})();
|
||||
|
||||
const { getLatestsSemVersionedTag } = getLatestsSemVersionedTagFactory({
|
||||
octokit
|
||||
});
|
||||
|
||||
return getLatestsSemVersionedTag;
|
||||
})();
|
||||
|
||||
const result = await getLatestsSemVersionedTag_stateless(params);
|
||||
|
||||
currentCache.entries.push({
|
||||
time: Date.now(),
|
||||
params,
|
||||
result
|
||||
});
|
||||
|
||||
{
|
||||
const dirPath = pathDirname(cacheFilePath);
|
||||
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(cacheFilePath, JSON.stringify(currentCache, null, 2));
|
||||
|
||||
return result;
|
||||
}
|
156
src/bin/shared/initializeSpa.ts
Normal file
156
src/bin/shared/initializeSpa.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { dirname as pathDirname, join as pathJoin, relative as pathRelative } from "path";
|
||||
import type { BuildContext } from "./buildContext";
|
||||
import * as fs from "fs";
|
||||
import { assert, is, type Equals } from "tsafe/assert";
|
||||
import { id } from "tsafe/id";
|
||||
import {
|
||||
addSyncExtensionsToPostinstallScript,
|
||||
type BuildContextLike as BuildContextLike_addSyncExtensionsToPostinstallScript
|
||||
} from "./addSyncExtensionsToPostinstallScript";
|
||||
import { getIsPrettierAvailable, runPrettier } from "../tools/runPrettier";
|
||||
import { npmInstall } from "../tools/npmInstall";
|
||||
import * as child_process from "child_process";
|
||||
import { z } from "zod";
|
||||
import chalk from "chalk";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_addSyncExtensionsToPostinstallScript & {
|
||||
themeSrcDirPath: string;
|
||||
packageJsonFilePath: string;
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export async function initializeSpa(params: {
|
||||
themeType: "account" | "admin";
|
||||
buildContext: BuildContextLike;
|
||||
}) {
|
||||
const { themeType, buildContext } = params;
|
||||
|
||||
{
|
||||
const themeTypeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, themeType);
|
||||
|
||||
if (
|
||||
fs.existsSync(themeTypeSrcDirPath) &&
|
||||
fs.readdirSync(themeTypeSrcDirPath).length > 0
|
||||
) {
|
||||
console.warn(
|
||||
chalk.red(
|
||||
`There is already a ${pathRelative(
|
||||
process.cwd(),
|
||||
themeTypeSrcDirPath
|
||||
)} directory in your project. Aborting.`
|
||||
)
|
||||
);
|
||||
|
||||
process.exit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
const parsedPackageJson = (() => {
|
||||
type ParsedPackageJson = {
|
||||
scripts?: Record<string, string | undefined>;
|
||||
dependencies?: Record<string, string | undefined>;
|
||||
devDependencies?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
const zParsedPackageJson = (() => {
|
||||
type TargetType = ParsedPackageJson;
|
||||
|
||||
const zTargetType = z.object({
|
||||
scripts: z.record(z.union([z.string(), z.undefined()])).optional(),
|
||||
dependencies: z.record(z.union([z.string(), z.undefined()])).optional(),
|
||||
devDependencies: z.record(z.union([z.string(), z.undefined()])).optional()
|
||||
});
|
||||
|
||||
assert<Equals<z.infer<typeof zTargetType>, TargetType>>;
|
||||
|
||||
return id<z.ZodType<TargetType>>(zTargetType);
|
||||
})();
|
||||
const parsedPackageJson = JSON.parse(
|
||||
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
|
||||
);
|
||||
|
||||
zParsedPackageJson.parse(parsedPackageJson);
|
||||
|
||||
assert(is<ParsedPackageJson>(parsedPackageJson));
|
||||
|
||||
return parsedPackageJson;
|
||||
})();
|
||||
|
||||
addSyncExtensionsToPostinstallScript({
|
||||
parsedPackageJson,
|
||||
buildContext
|
||||
});
|
||||
|
||||
const uiSharedMajor = (() => {
|
||||
const dependencies = {
|
||||
...parsedPackageJson.devDependencies,
|
||||
...parsedPackageJson.dependencies
|
||||
};
|
||||
|
||||
const version = dependencies["@keycloakify/keycloak-ui-shared"];
|
||||
|
||||
if (version === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = version.match(/^[^~]?(\d+)\./);
|
||||
|
||||
if (match === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return match[1];
|
||||
})();
|
||||
|
||||
const moduleName = `@keycloakify/keycloak-${themeType}-ui`;
|
||||
|
||||
const version = ((): string[] => {
|
||||
const cmdOutput = child_process
|
||||
.execSync(`npm show ${moduleName} versions --json`)
|
||||
.toString("utf8")
|
||||
.trim();
|
||||
|
||||
const versions = JSON.parse(cmdOutput) as string | string[];
|
||||
|
||||
// NOTE: Bug in some older npm versions
|
||||
if (typeof versions === "string") {
|
||||
return [versions];
|
||||
}
|
||||
|
||||
return versions;
|
||||
})()
|
||||
.reverse()
|
||||
.filter(version => !version.includes("-"))
|
||||
.find(version =>
|
||||
uiSharedMajor === undefined ? true : version.startsWith(`${uiSharedMajor}.`)
|
||||
);
|
||||
|
||||
assert(version !== undefined);
|
||||
|
||||
(parsedPackageJson.dependencies ??= {})[moduleName] = `~${version}`;
|
||||
|
||||
if (parsedPackageJson.devDependencies !== undefined) {
|
||||
delete parsedPackageJson.devDependencies[moduleName];
|
||||
}
|
||||
|
||||
{
|
||||
let sourceCode = JSON.stringify(parsedPackageJson, undefined, 2);
|
||||
|
||||
if (await getIsPrettierAvailable()) {
|
||||
sourceCode = await runPrettier({
|
||||
sourceCode,
|
||||
filePath: buildContext.packageJsonFilePath
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
buildContext.packageJsonFilePath,
|
||||
Buffer.from(sourceCode, "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
await npmInstall({
|
||||
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
|
||||
});
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
import { join as pathJoin, dirname as pathDirname } from "path";
|
||||
import type { ThemeType } from "./constants";
|
||||
import * as fs from "fs";
|
||||
|
||||
export type MetaInfKeycloakTheme = {
|
||||
themes: { name: string; types: (ThemeType | "email")[] }[];
|
||||
};
|
||||
|
||||
export function writeMetaInfKeycloakThemes(params: {
|
||||
resourcesDirPath: string;
|
||||
getNewMetaInfKeycloakTheme: (params: {
|
||||
metaInfKeycloakTheme: MetaInfKeycloakTheme | undefined;
|
||||
}) => MetaInfKeycloakTheme;
|
||||
}) {
|
||||
const { resourcesDirPath, getNewMetaInfKeycloakTheme } = params;
|
||||
|
||||
const filePath = pathJoin(resourcesDirPath, "META-INF", "keycloak-themes.json");
|
||||
|
||||
const currentMetaInfKeycloakTheme = !fs.existsSync(filePath)
|
||||
? undefined
|
||||
: (JSON.parse(
|
||||
fs.readFileSync(filePath).toString("utf8")
|
||||
) as MetaInfKeycloakTheme);
|
||||
|
||||
const newMetaInfKeycloakThemes = getNewMetaInfKeycloakTheme({
|
||||
metaInfKeycloakTheme: currentMetaInfKeycloakTheme
|
||||
});
|
||||
|
||||
{
|
||||
const dirPath = pathDirname(filePath);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
Buffer.from(JSON.stringify(newMetaInfKeycloakThemes, null, 2), "utf8")
|
||||
);
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
import {
|
||||
getLatestsSemVersionedTag,
|
||||
type BuildContextLike as BuildContextLike_getLatestsSemVersionedTag
|
||||
} from "./getLatestsSemVersionedTag";
|
||||
import cliSelect from "cli-select";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { SemVer } from "../tools/SemVer";
|
||||
import type { BuildContext } from "./buildContext";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_getLatestsSemVersionedTag & {};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export async function promptKeycloakVersion(params: {
|
||||
startingFromMajor: number | undefined;
|
||||
excludeMajorVersions: number[];
|
||||
doOmitPatch: boolean;
|
||||
buildContext: BuildContextLike;
|
||||
}) {
|
||||
const { startingFromMajor, excludeMajorVersions, doOmitPatch, buildContext } = params;
|
||||
|
||||
const semVersionedTagByMajor = new Map<number, { tag: string; version: SemVer }>();
|
||||
|
||||
const semVersionedTags = await getLatestsSemVersionedTag({
|
||||
count: 50,
|
||||
owner: "keycloak",
|
||||
repo: "keycloak",
|
||||
doIgnoreReleaseCandidates: true,
|
||||
buildContext
|
||||
});
|
||||
|
||||
semVersionedTags.forEach(semVersionedTag => {
|
||||
if (
|
||||
startingFromMajor !== undefined &&
|
||||
semVersionedTag.version.major < startingFromMajor
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (excludeMajorVersions.includes(semVersionedTag.version.major)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSemVersionedTag = semVersionedTagByMajor.get(
|
||||
semVersionedTag.version.major
|
||||
);
|
||||
|
||||
if (
|
||||
currentSemVersionedTag !== undefined &&
|
||||
SemVer.compare(semVersionedTag.version, currentSemVersionedTag.version) === -1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
semVersionedTagByMajor.set(semVersionedTag.version.major, semVersionedTag);
|
||||
});
|
||||
|
||||
const lastMajorVersions = Array.from(semVersionedTagByMajor.values()).map(
|
||||
({ version }) =>
|
||||
`${version.major}.${version.minor}${doOmitPatch ? "" : `.${version.patch}`}`
|
||||
);
|
||||
|
||||
const { value } = await cliSelect<string>({
|
||||
values: lastMajorVersions
|
||||
}).catch(() => {
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
const keycloakVersion = value.split(" ")[0];
|
||||
|
||||
return { keycloakVersion };
|
||||
}
|
@ -1,28 +1,20 @@
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { ParsedRealmJson } from "./ParsedRealmJson";
|
||||
import { getDefaultConfig } from "./defaultConfig";
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
import { objectKeys } from "tsafe/objectKeys";
|
||||
import { TEST_APP_URL } from "../../shared/constants";
|
||||
import { TEST_APP_URL, type ThemeType, THEME_TYPES } from "../../shared/constants";
|
||||
import { sameFactory } from "evt/tools/inDepth/same";
|
||||
|
||||
export type BuildContextLike = {
|
||||
themeNames: BuildContext["themeNames"];
|
||||
implementedThemeTypes: BuildContext["implementedThemeTypes"];
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>;
|
||||
|
||||
export function prepareRealmConfig(params: {
|
||||
parsedRealmJson: ParsedRealmJson;
|
||||
keycloakMajorVersionNumber: number;
|
||||
buildContext: BuildContextLike;
|
||||
parsedKeycloakThemesJsonEntry: { name: string; types: (ThemeType | "email")[] };
|
||||
}): {
|
||||
realmName: string;
|
||||
clientName: string;
|
||||
username: string;
|
||||
} {
|
||||
const { parsedRealmJson, keycloakMajorVersionNumber, buildContext } = params;
|
||||
const { parsedRealmJson, keycloakMajorVersionNumber, parsedKeycloakThemesJsonEntry } =
|
||||
params;
|
||||
|
||||
const { username } = addOrEditTestUser({
|
||||
parsedRealmJson,
|
||||
@ -38,8 +30,7 @@ export function prepareRealmConfig(params: {
|
||||
|
||||
enableCustomThemes({
|
||||
parsedRealmJson,
|
||||
themeName: buildContext.themeNames[0],
|
||||
implementedThemeTypes: buildContext.implementedThemeTypes
|
||||
parsedKeycloakThemesJsonEntry
|
||||
});
|
||||
|
||||
enable_custom_events_listeners: {
|
||||
@ -63,17 +54,15 @@ export function prepareRealmConfig(params: {
|
||||
|
||||
function enableCustomThemes(params: {
|
||||
parsedRealmJson: ParsedRealmJson;
|
||||
themeName: string;
|
||||
implementedThemeTypes: BuildContextLike["implementedThemeTypes"];
|
||||
parsedKeycloakThemesJsonEntry: { name: string; types: (ThemeType | "email")[] };
|
||||
}) {
|
||||
const { parsedRealmJson, themeName, implementedThemeTypes } = params;
|
||||
const { parsedRealmJson, parsedKeycloakThemesJsonEntry } = params;
|
||||
|
||||
for (const themeType of objectKeys(implementedThemeTypes)) {
|
||||
if (!implementedThemeTypes[themeType].isImplemented) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsedRealmJson[`${themeType}Theme` as const] = themeName;
|
||||
for (const themeType of [...THEME_TYPES, "email"] as const) {
|
||||
parsedRealmJson[`${themeType}Theme` as const] =
|
||||
!parsedKeycloakThemesJsonEntry.types.includes(themeType)
|
||||
? ""
|
||||
: parsedKeycloakThemesJsonEntry.name;
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,7 +101,6 @@ function addOrEditTestUser(params: {
|
||||
);
|
||||
|
||||
newUser.username = defaultUser_default.username;
|
||||
newUser.email = defaultUser_default.email;
|
||||
|
||||
delete_existing_password_credential_if_any: {
|
||||
const i = newUser.credentials.findIndex(
|
||||
|
@ -1,10 +1,7 @@
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { getDefaultConfig } from "./defaultConfig";
|
||||
import {
|
||||
prepareRealmConfig,
|
||||
type BuildContextLike as BuildContextLike_prepareRealmConfig
|
||||
} from "./prepareRealmConfig";
|
||||
import { prepareRealmConfig } from "./prepareRealmConfig";
|
||||
import * as fs from "fs";
|
||||
import {
|
||||
join as pathJoin,
|
||||
@ -24,18 +21,19 @@ import {
|
||||
} from "./dumpContainerConfig";
|
||||
import * as runExclusive from "run-exclusive";
|
||||
import { waitForDebounceFactory } from "powerhooks/tools/waitForDebounce";
|
||||
import type { ThemeType } from "../../shared/constants";
|
||||
import chalk from "chalk";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_dumpContainerConfig &
|
||||
BuildContextLike_prepareRealmConfig & {
|
||||
projectDirPath: string;
|
||||
};
|
||||
export type BuildContextLike = BuildContextLike_dumpContainerConfig & {
|
||||
projectDirPath: string;
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>;
|
||||
|
||||
export async function getRealmConfig(params: {
|
||||
keycloakMajorVersionNumber: number;
|
||||
realmJsonFilePath_userProvided: string | undefined;
|
||||
parsedKeycloakThemesJsonEntry: { name: string; types: (ThemeType | "email")[] };
|
||||
buildContext: BuildContextLike;
|
||||
}): Promise<{
|
||||
realmJsonFilePath: string;
|
||||
@ -44,8 +42,12 @@ export async function getRealmConfig(params: {
|
||||
username: string;
|
||||
onRealmConfigChange: () => Promise<void>;
|
||||
}> {
|
||||
const { keycloakMajorVersionNumber, realmJsonFilePath_userProvided, buildContext } =
|
||||
params;
|
||||
const {
|
||||
keycloakMajorVersionNumber,
|
||||
realmJsonFilePath_userProvided,
|
||||
parsedKeycloakThemesJsonEntry,
|
||||
buildContext
|
||||
} = params;
|
||||
|
||||
const realmJsonFilePath = pathJoin(
|
||||
buildContext.projectDirPath,
|
||||
@ -71,8 +73,8 @@ export async function getRealmConfig(params: {
|
||||
|
||||
const { clientName, realmName, username } = prepareRealmConfig({
|
||||
parsedRealmJson,
|
||||
buildContext,
|
||||
keycloakMajorVersionNumber
|
||||
keycloakMajorVersionNumber,
|
||||
parsedKeycloakThemesJsonEntry
|
||||
});
|
||||
|
||||
{
|
||||
|
@ -4,7 +4,8 @@ import {
|
||||
CONTAINER_NAME,
|
||||
KEYCLOAKIFY_SPA_DEV_SERVER_PORT,
|
||||
KEYCLOAKIFY_LOGIN_JAR_BASENAME,
|
||||
TEST_APP_URL
|
||||
TEST_APP_URL,
|
||||
ThemeType
|
||||
} from "../shared/constants";
|
||||
import { SemVer } from "../tools/SemVer";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
@ -34,6 +35,7 @@ import { startViteDevServer } from "./startViteDevServer";
|
||||
import { getSupportedKeycloakMajorVersions } from "./realmConfig/defaultConfig";
|
||||
import { getSupportedDockerImageTags } from "./getSupportedDockerImageTags";
|
||||
import { getRealmConfig } from "./realmConfig";
|
||||
import { id } from "tsafe/id";
|
||||
|
||||
export async function command(params: {
|
||||
buildContext: BuildContext;
|
||||
@ -51,11 +53,17 @@ export async function command(params: {
|
||||
.execSync("docker --version", {
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
})
|
||||
?.toString("utf8");
|
||||
} catch {}
|
||||
.toString("utf8");
|
||||
} catch {
|
||||
commandOutput = "";
|
||||
}
|
||||
|
||||
if (commandOutput?.includes("Docker") || commandOutput?.includes("podman")) {
|
||||
break exit_if_docker_not_installed;
|
||||
commandOutput = commandOutput.trim().toLowerCase();
|
||||
|
||||
for (const term of ["docker", "podman"]) {
|
||||
if (commandOutput.includes(term)) {
|
||||
break exit_if_docker_not_installed;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
@ -270,32 +278,6 @@ export async function command(params: {
|
||||
return wrap.majorVersionNumber;
|
||||
})();
|
||||
|
||||
const { clientName, onRealmConfigChange, realmJsonFilePath, realmName, username } =
|
||||
await getRealmConfig({
|
||||
keycloakMajorVersionNumber,
|
||||
realmJsonFilePath_userProvided: await (async () => {
|
||||
if (cliCommandOptions.realmJsonFilePath !== undefined) {
|
||||
return getAbsoluteAndInOsFormatPath({
|
||||
pathIsh: cliCommandOptions.realmJsonFilePath,
|
||||
cwd: process.cwd()
|
||||
});
|
||||
}
|
||||
|
||||
if (buildContext.startKeycloakOptions.realmJsonFilePath !== undefined) {
|
||||
assert(
|
||||
await existsAsync(
|
||||
buildContext.startKeycloakOptions.realmJsonFilePath
|
||||
),
|
||||
`${pathRelative(process.cwd(), buildContext.startKeycloakOptions.realmJsonFilePath)} does not exist`
|
||||
);
|
||||
return buildContext.startKeycloakOptions.realmJsonFilePath;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})(),
|
||||
buildContext
|
||||
});
|
||||
|
||||
{
|
||||
const { isAppBuildSuccess } = await appBuild({
|
||||
buildContext
|
||||
@ -376,10 +358,24 @@ export async function command(params: {
|
||||
))
|
||||
];
|
||||
|
||||
let parsedKeycloakThemesJson = id<
|
||||
{ themes: { name: string; types: (ThemeType | "email")[] }[] } | undefined
|
||||
>(undefined);
|
||||
|
||||
async function extractThemeResourcesFromJar() {
|
||||
await extractArchive({
|
||||
archiveFilePath: jarFilePath,
|
||||
onArchiveFile: async ({ relativeFilePathInArchive, writeFile }) => {
|
||||
onArchiveFile: async ({ relativeFilePathInArchive, writeFile, readFile }) => {
|
||||
if (
|
||||
relativeFilePathInArchive ===
|
||||
pathJoin("META-INF", "keycloak-themes.json") &&
|
||||
parsedKeycloakThemesJson === undefined
|
||||
) {
|
||||
parsedKeycloakThemesJson = JSON.parse(
|
||||
(await readFile()).toString("utf8")
|
||||
);
|
||||
}
|
||||
|
||||
if (isInside({ dirPath: "theme", filePath: relativeFilePathInArchive })) {
|
||||
await writeFile({
|
||||
filePath: pathJoin(
|
||||
@ -401,6 +397,43 @@ export async function command(params: {
|
||||
|
||||
await extractThemeResourcesFromJar();
|
||||
|
||||
assert(parsedKeycloakThemesJson !== undefined);
|
||||
|
||||
const { clientName, onRealmConfigChange, realmJsonFilePath, realmName, username } =
|
||||
await getRealmConfig({
|
||||
keycloakMajorVersionNumber,
|
||||
parsedKeycloakThemesJsonEntry: (() => {
|
||||
const entry = parsedKeycloakThemesJson.themes.find(
|
||||
({ name }) => name === buildContext.themeNames[0]
|
||||
);
|
||||
|
||||
assert(entry !== undefined);
|
||||
|
||||
return entry;
|
||||
})(),
|
||||
realmJsonFilePath_userProvided: await (async () => {
|
||||
if (cliCommandOptions.realmJsonFilePath !== undefined) {
|
||||
return getAbsoluteAndInOsFormatPath({
|
||||
pathIsh: cliCommandOptions.realmJsonFilePath,
|
||||
cwd: process.cwd()
|
||||
});
|
||||
}
|
||||
|
||||
if (buildContext.startKeycloakOptions.realmJsonFilePath !== undefined) {
|
||||
assert(
|
||||
await existsAsync(
|
||||
buildContext.startKeycloakOptions.realmJsonFilePath
|
||||
),
|
||||
`${pathRelative(process.cwd(), buildContext.startKeycloakOptions.realmJsonFilePath)} does not exist`
|
||||
);
|
||||
return buildContext.startKeycloakOptions.realmJsonFilePath;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})(),
|
||||
buildContext
|
||||
});
|
||||
|
||||
const jarFilePath_cacheDir = pathJoin(
|
||||
buildContext.cacheDirPath,
|
||||
pathBasename(jarFilePath)
|
||||
@ -754,6 +787,40 @@ export async function command(params: {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
ignore_patternfly: {
|
||||
if (
|
||||
!isInside({
|
||||
dirPath: pathJoin(
|
||||
buildContext.themeSrcDirPath,
|
||||
"shared",
|
||||
"@patternfly"
|
||||
),
|
||||
filePath
|
||||
})
|
||||
) {
|
||||
break ignore_patternfly;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
ignore_keycloak_ui_shared: {
|
||||
if (
|
||||
!isInside({
|
||||
dirPath: pathJoin(
|
||||
buildContext.themeSrcDirPath,
|
||||
"shared",
|
||||
"keycloak-ui-shared"
|
||||
),
|
||||
filePath
|
||||
})
|
||||
) {
|
||||
break ignore_keycloak_ui_shared;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Detected changes in ${filePath}`);
|
||||
|
@ -109,7 +109,6 @@ export async function getExtensionModuleMetas(params: {
|
||||
const installedExtensionModules = await (async () => {
|
||||
const installedModulesWithKeycloakifyInTheName = await listInstalledModules({
|
||||
packageJsonFilePath: buildContext.packageJsonFilePath,
|
||||
projectDirPath: buildContext.packageJsonFilePath,
|
||||
filter: ({ moduleName }) =>
|
||||
moduleName.includes("keycloakify") && moduleName !== "keycloakify"
|
||||
});
|
||||
|
@ -45,12 +45,12 @@ export async function getExtensionModuleFileSourceCodeReadyToBeCopied(params: {
|
||||
`This file has been claimed for ownership from ${extensionModuleName} version ${extensionModuleVersion}.`,
|
||||
`To relinquish ownership and restore this file to its original content, run the following command:`,
|
||||
``,
|
||||
`$ npx keycloakify own --path '${path}' --revert`
|
||||
`$ npx keycloakify own --path "${path}" --revert`
|
||||
]
|
||||
: [
|
||||
`WARNING: Before modifying this file, run the following command:`,
|
||||
``,
|
||||
`$ npx keycloakify own --path '${path}'`,
|
||||
`$ npx keycloakify own --path "${path}"`,
|
||||
``,
|
||||
`This file is provided by ${extensionModuleName} version ${extensionModuleVersion}.`,
|
||||
`It was copied into your repository by the postinstall script: \`keycloakify sync-extensions\`.`
|
||||
@ -99,12 +99,31 @@ function addCommentToSourceCode(params: {
|
||||
return toResult(commentLines.map(line => `# ${line}`).join("\n"));
|
||||
}
|
||||
|
||||
if (fileRelativePath.endsWith(".ftl")) {
|
||||
const comment = [`<#--`, ...commentLines.map(line => ` ${line}`), `-->`].join(
|
||||
"\n"
|
||||
);
|
||||
|
||||
if (sourceCode.trim().startsWith("<#ftl")) {
|
||||
const [first, ...rest] = sourceCode.split(">");
|
||||
|
||||
const last = rest.join(">");
|
||||
|
||||
return [`${first}>`, comment, last].join("\n");
|
||||
}
|
||||
|
||||
return toResult(comment);
|
||||
}
|
||||
|
||||
if (fileRelativePath.endsWith(".html") || fileRelativePath.endsWith(".svg")) {
|
||||
const comment = [
|
||||
`<!--`,
|
||||
...commentLines.map(
|
||||
line =>
|
||||
` ${line.replace("--path", "-p").replace("Before modifying", "Before modifying or replacing")}`
|
||||
` ${line
|
||||
.replace("--path", "-t")
|
||||
.replace("--revert", "-r")
|
||||
.replace("Before modifying", "Before modifying or replacing")}`
|
||||
),
|
||||
`-->`
|
||||
].join("\n");
|
||||
|
@ -1,12 +0,0 @@
|
||||
type PropertiesThatCanBeUndefined<T extends Record<string, unknown>> = {
|
||||
[Key in keyof T]: undefined extends T[Key] ? Key : never;
|
||||
}[keyof T];
|
||||
|
||||
/**
|
||||
* OptionalIfCanBeUndefined<{ p1: string | undefined; p2: string; }>
|
||||
* is
|
||||
* { p1?: string | undefined; p2: string }
|
||||
*/
|
||||
export type OptionalIfCanBeUndefined<T extends Record<string, unknown>> = {
|
||||
[K in PropertiesThatCanBeUndefined<T>]?: T[K];
|
||||
} & { [K in Exclude<keyof T, PropertiesThatCanBeUndefined<T>>]: T[K] };
|
@ -1,73 +0,0 @@
|
||||
import { Readable } from "stream";
|
||||
|
||||
const crc32tab = [
|
||||
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535,
|
||||
0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd,
|
||||
0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d,
|
||||
0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec,
|
||||
0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4,
|
||||
0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c,
|
||||
0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac,
|
||||
0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f,
|
||||
0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab,
|
||||
0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f,
|
||||
0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb,
|
||||
0x086d3d2d, 0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e,
|
||||
0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea,
|
||||
0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, 0x4db26158, 0x3ab551ce,
|
||||
0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a,
|
||||
0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9,
|
||||
0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409,
|
||||
0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81,
|
||||
0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739,
|
||||
0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8,
|
||||
0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344, 0x8708a3d2, 0x1e01f268,
|
||||
0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0,
|
||||
0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8,
|
||||
0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
|
||||
0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef,
|
||||
0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703,
|
||||
0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7,
|
||||
0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a,
|
||||
0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae,
|
||||
0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242,
|
||||
0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, 0x88085ae6,
|
||||
0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45,
|
||||
0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d,
|
||||
0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5,
|
||||
0x47b2cf7f, 0x30b5ffe9, 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605,
|
||||
0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
|
||||
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d
|
||||
];
|
||||
|
||||
/**
|
||||
*
|
||||
* @param input either a byte stream, a string or a buffer, you want to have the checksum for
|
||||
* @returns a promise for a checksum (uint32)
|
||||
*/
|
||||
export function crc32(input: Readable | String | Buffer): Promise<number> {
|
||||
if (typeof input === "string") {
|
||||
let crc = ~0;
|
||||
for (let i = 0; i < input.length; i++)
|
||||
crc = (crc >>> 8) ^ crc32tab[(crc ^ input.charCodeAt(i)) & 0xff];
|
||||
return Promise.resolve((crc ^ -1) >>> 0);
|
||||
} else if (input instanceof Buffer) {
|
||||
let crc = ~0;
|
||||
for (let i = 0; i < input.length; i++)
|
||||
crc = (crc >>> 8) ^ crc32tab[(crc ^ input[i]) & 0xff];
|
||||
return Promise.resolve((crc ^ -1) >>> 0);
|
||||
} else if (input instanceof Readable) {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
let crc = ~0;
|
||||
input.setMaxListeners(Infinity);
|
||||
input.on("end", () => resolve((crc ^ -1) >>> 0));
|
||||
input.on("error", e => reject(e));
|
||||
input.on("data", (chunk: Buffer) => {
|
||||
for (let i = 0; i < chunk.length; i++)
|
||||
crc = (crc >>> 8) ^ crc32tab[(crc ^ chunk[i]) & 0xff];
|
||||
});
|
||||
});
|
||||
} else {
|
||||
throw new Error("Unsupported input " + typeof input);
|
||||
}
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
import { PassThrough, Readable, TransformCallback, Writable } from "stream";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { deflateRaw as deflateRawCb, createDeflateRaw } from "zlib";
|
||||
import { promisify } from "util";
|
||||
|
||||
import { crc32 } from "./crc32";
|
||||
import tee from "./tee";
|
||||
|
||||
const deflateRaw = promisify(deflateRawCb);
|
||||
|
||||
/**
|
||||
* A stream transformer that records the number of bytes
|
||||
* passed in its `size` property.
|
||||
*/
|
||||
class ByteCounter extends PassThrough {
|
||||
size: number = 0;
|
||||
_transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback) {
|
||||
if ("length" in chunk) this.size += chunk.length;
|
||||
super._transform(chunk, encoding, callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param data buffer containing the data to be compressed
|
||||
* @returns a buffer containing the compressed/deflated data and the crc32 checksum
|
||||
* of the source data
|
||||
*/
|
||||
export async function deflateBuffer(data: Buffer) {
|
||||
const [deflated, checksum] = await Promise.all([deflateRaw(data), crc32(data)]);
|
||||
return { deflated, crc32: checksum };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param input a byte stream, containing data to be compressed
|
||||
* @param sink a method that will accept chunks of compressed data; We don't pass
|
||||
* a writable here, since we don't want the writablestream to be closed after
|
||||
* a single file
|
||||
* @returns a promise, which will resolve with the crc32 checksum and the
|
||||
* compressed size
|
||||
*/
|
||||
export async function deflateStream(input: Readable, sink: (chunk: Buffer) => void) {
|
||||
const deflateWriter = new Writable({
|
||||
write(chunk, _, callback) {
|
||||
sink(chunk);
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
// tee the input stream, so we can compress and calc crc32 in parallel
|
||||
const [rs1, rs2] = tee(input);
|
||||
const byteCounter = new ByteCounter();
|
||||
const [_, crc] = await Promise.all([
|
||||
// pipe input into zip compressor, count the bytes
|
||||
// returned and pass compressed data to the sink
|
||||
pipeline(rs1, createDeflateRaw(), byteCounter, deflateWriter),
|
||||
// calc checksum
|
||||
crc32(rs2)
|
||||
]);
|
||||
|
||||
return { crc32: crc, compressedSize: byteCounter.size };
|
||||
}
|
@ -14,6 +14,8 @@ export function getAbsoluteAndInOsFormatPath(params: {
|
||||
|
||||
let pathOut = pathIsh;
|
||||
|
||||
pathOut = pathOut.replace(/^['"]/, "").replace(/['"]$/, "");
|
||||
|
||||
pathOut = pathOut.replace(/\//g, pathSep);
|
||||
|
||||
if (pathOut.startsWith("~")) {
|
||||
|
@ -2,40 +2,42 @@ import { join as pathJoin } from "path";
|
||||
import { existsAsync } from "./fs.existsAsync";
|
||||
import * as child_process from "child_process";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { getIsRootPath } from "../tools/isRootPath";
|
||||
|
||||
export async function getInstalledModuleDirPath(params: {
|
||||
moduleName: string;
|
||||
packageJsonDirPath: string;
|
||||
projectDirPath: string;
|
||||
}) {
|
||||
const { moduleName, packageJsonDirPath, projectDirPath } = params;
|
||||
const { moduleName, packageJsonDirPath } = params;
|
||||
|
||||
common_case: {
|
||||
const dirPath = pathJoin(
|
||||
...[packageJsonDirPath, "node_modules", ...moduleName.split("/")]
|
||||
);
|
||||
{
|
||||
let dirPath = packageJsonDirPath;
|
||||
|
||||
if (!(await existsAsync(dirPath))) {
|
||||
break common_case;
|
||||
while (true) {
|
||||
const dirPath_candidate = pathJoin(
|
||||
dirPath,
|
||||
"node_modules",
|
||||
...moduleName.split("/")
|
||||
);
|
||||
|
||||
let doesExist: boolean;
|
||||
|
||||
try {
|
||||
doesExist = await existsAsync(dirPath_candidate);
|
||||
} catch {
|
||||
doesExist = false;
|
||||
}
|
||||
|
||||
if (doesExist) {
|
||||
return dirPath_candidate;
|
||||
}
|
||||
|
||||
if (getIsRootPath(dirPath)) {
|
||||
break;
|
||||
}
|
||||
|
||||
dirPath = pathJoin(dirPath, "..");
|
||||
}
|
||||
|
||||
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
|
||||
|
22
src/bin/tools/isRootPath.ts
Normal file
22
src/bin/tools/isRootPath.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { normalize as pathNormalize } from "path";
|
||||
|
||||
export function getIsRootPath(filePath: string): boolean {
|
||||
const path_normalized = pathNormalize(filePath);
|
||||
|
||||
// Unix-like root ("/")
|
||||
if (path_normalized === "/") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for Windows drive root (e.g., "C:\\")
|
||||
if (/^[a-zA-Z]:\\$/.test(path_normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for UNC root (e.g., "\\server\share")
|
||||
if (/^\\\\[^\\]+\\[^\\]+\\?$/.test(path_normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
@ -8,7 +8,6 @@ import { exclude } from "tsafe/exclude";
|
||||
|
||||
export async function listInstalledModules(params: {
|
||||
packageJsonFilePath: string;
|
||||
projectDirPath: string;
|
||||
filter: (params: { moduleName: string }) => boolean;
|
||||
}): Promise<
|
||||
{
|
||||
@ -18,7 +17,7 @@ export async function listInstalledModules(params: {
|
||||
peerDependencies: Record<string, string>;
|
||||
}[]
|
||||
> {
|
||||
const { packageJsonFilePath, projectDirPath, filter } = params;
|
||||
const { packageJsonFilePath, filter } = params;
|
||||
|
||||
const parsedPackageJson = await readPackageJsonDependencies({
|
||||
packageJsonFilePath
|
||||
@ -36,8 +35,7 @@ export async function listInstalledModules(params: {
|
||||
extensionModuleNames.map(async moduleName => {
|
||||
const dirPath = await getInstalledModuleDirPath({
|
||||
moduleName,
|
||||
packageJsonDirPath: pathDirname(packageJsonFilePath),
|
||||
projectDirPath
|
||||
packageJsonDirPath: pathDirname(packageJsonFilePath)
|
||||
});
|
||||
|
||||
const { version, peerDependencies } =
|
||||
|
@ -1,10 +1,29 @@
|
||||
import { sep as pathSep } from "path";
|
||||
import { sep as pathSep, dirname as pathDirname, join as pathJoin } from "path";
|
||||
import { getThisCodebaseRootDirPath } from "./getThisCodebaseRootDirPath";
|
||||
import { getInstalledModuleDirPath } from "./getInstalledModuleDirPath";
|
||||
import { existsAsync } from "./fs.existsAsync";
|
||||
import { z } from "zod";
|
||||
import * as fs from "fs/promises";
|
||||
import { assert, is, type Equals } from "tsafe/assert";
|
||||
import { id } from "tsafe/id";
|
||||
|
||||
let cache: string | undefined = undefined;
|
||||
let cache_bestEffort: string | undefined = undefined;
|
||||
|
||||
export function getNodeModulesBinDirPath() {
|
||||
if (cache !== undefined) {
|
||||
return cache;
|
||||
/** NOTE: Careful, this function can fail when the binary
|
||||
* Used is not in the node_modules directory of the project
|
||||
* (for example when running tests with vscode extension we'll get
|
||||
* '/Users/dylan/.vscode/extensions/vitest.explorer-1.16.0/dist/worker.js'
|
||||
*
|
||||
* instead of
|
||||
* '/Users/joseph/.nvm/versions/node/v22.12.0/bin/node'
|
||||
* or
|
||||
* '/Users/joseph/github/keycloakify-starter/node_modules/.bin/vite'
|
||||
*
|
||||
* as the value of process.argv[1]
|
||||
*/
|
||||
function getNodeModulesBinDirPath_bestEffort() {
|
||||
if (cache_bestEffort !== undefined) {
|
||||
return cache_bestEffort;
|
||||
}
|
||||
|
||||
const binPath = process.argv[1];
|
||||
@ -30,9 +49,122 @@ export function getNodeModulesBinDirPath() {
|
||||
segments.unshift(segment);
|
||||
}
|
||||
|
||||
if (!foundNodeModules) {
|
||||
throw new Error(`Could not find node_modules in path ${binPath}`);
|
||||
}
|
||||
|
||||
const nodeModulesBinDirPath = segments.join(pathSep);
|
||||
|
||||
cache = nodeModulesBinDirPath;
|
||||
cache_bestEffort = nodeModulesBinDirPath;
|
||||
|
||||
return nodeModulesBinDirPath;
|
||||
}
|
||||
|
||||
let cache_withPackageJsonFileDirPath:
|
||||
| { packageJsonFilePath: string; nodeModulesBinDirPath: string }
|
||||
| undefined = undefined;
|
||||
|
||||
async function getNodeModulesBinDirPath_withPackageJsonFileDirPath(params: {
|
||||
packageJsonFilePath: string;
|
||||
}): Promise<string> {
|
||||
const { packageJsonFilePath } = params;
|
||||
|
||||
use_cache: {
|
||||
if (cache_withPackageJsonFileDirPath === undefined) {
|
||||
break use_cache;
|
||||
}
|
||||
|
||||
if (
|
||||
cache_withPackageJsonFileDirPath.packageJsonFilePath !== packageJsonFilePath
|
||||
) {
|
||||
cache_withPackageJsonFileDirPath = undefined;
|
||||
break use_cache;
|
||||
}
|
||||
|
||||
return cache_withPackageJsonFileDirPath.nodeModulesBinDirPath;
|
||||
}
|
||||
|
||||
// [...]node_modules/keycloakify
|
||||
const installedModuleDirPath = await getInstalledModuleDirPath({
|
||||
// Here it will always be "keycloakify" but since we are in tools/ we make something generic
|
||||
moduleName: await (async () => {
|
||||
type ParsedPackageJson = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
const zParsedPackageJson = (() => {
|
||||
type TargetType = ParsedPackageJson;
|
||||
|
||||
const zTargetType = z.object({
|
||||
name: z.string()
|
||||
});
|
||||
|
||||
assert<Equals<z.infer<typeof zTargetType>, TargetType>>;
|
||||
|
||||
return id<z.ZodType<TargetType>>(zTargetType);
|
||||
})();
|
||||
|
||||
const parsedPackageJson = JSON.parse(
|
||||
(
|
||||
await fs.readFile(
|
||||
pathJoin(getThisCodebaseRootDirPath(), "package.json")
|
||||
)
|
||||
).toString("utf8")
|
||||
);
|
||||
|
||||
zParsedPackageJson.parse(parsedPackageJson);
|
||||
|
||||
assert(is<ParsedPackageJson>(parsedPackageJson));
|
||||
|
||||
return parsedPackageJson.name;
|
||||
})(),
|
||||
packageJsonDirPath: pathDirname(packageJsonFilePath)
|
||||
});
|
||||
|
||||
const segments = installedModuleDirPath.split(pathSep);
|
||||
|
||||
while (true) {
|
||||
const segment = segments.pop();
|
||||
|
||||
if (segment === undefined) {
|
||||
throw new Error(
|
||||
`Could not find .bin directory relative to ${packageJsonFilePath}`
|
||||
);
|
||||
}
|
||||
|
||||
if (segment !== "node_modules") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = pathJoin(segments.join(pathSep), segment, ".bin");
|
||||
|
||||
if (!(await existsAsync(candidate))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cache_withPackageJsonFileDirPath = {
|
||||
packageJsonFilePath,
|
||||
nodeModulesBinDirPath: candidate
|
||||
};
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return cache_withPackageJsonFileDirPath.nodeModulesBinDirPath;
|
||||
}
|
||||
|
||||
export function getNodeModulesBinDirPath(params: {
|
||||
packageJsonFilePath: string;
|
||||
}): Promise<string>;
|
||||
export function getNodeModulesBinDirPath(params: {
|
||||
packageJsonFilePath: undefined;
|
||||
}): string;
|
||||
export function getNodeModulesBinDirPath(params: {
|
||||
packageJsonFilePath: string | undefined;
|
||||
}): string | Promise<string> {
|
||||
const { packageJsonFilePath } = params ?? {};
|
||||
|
||||
return packageJsonFilePath === undefined
|
||||
? getNodeModulesBinDirPath_bestEffort()
|
||||
: getNodeModulesBinDirPath_withPackageJsonFileDirPath({ packageJsonFilePath });
|
||||
}
|
||||
|
@ -1,47 +0,0 @@
|
||||
import { listTagsFactory } from "./listTags";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { SemVer } from "../SemVer";
|
||||
|
||||
export function getLatestsSemVersionedTagFactory(params: { octokit: Octokit }) {
|
||||
const { octokit } = params;
|
||||
|
||||
async function getLatestsSemVersionedTag(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
count: number;
|
||||
doIgnoreReleaseCandidates: boolean;
|
||||
}): Promise<
|
||||
{
|
||||
tag: string;
|
||||
version: SemVer;
|
||||
}[]
|
||||
> {
|
||||
const { owner, repo, count, doIgnoreReleaseCandidates } = params;
|
||||
|
||||
const semVersionedTags: { tag: string; version: SemVer }[] = [];
|
||||
|
||||
const { listTags } = listTagsFactory({ octokit });
|
||||
|
||||
for await (const tag of listTags({ owner, repo })) {
|
||||
let version: SemVer;
|
||||
|
||||
try {
|
||||
version = SemVer.parse(tag.replace(/^[vV]?/, ""));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (doIgnoreReleaseCandidates && version.rc !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
semVersionedTags.push({ tag, version });
|
||||
}
|
||||
|
||||
return semVersionedTags
|
||||
.sort(({ version: vX }, { version: vY }) => SemVer.compare(vY, vX))
|
||||
.slice(0, count);
|
||||
}
|
||||
|
||||
return { getLatestsSemVersionedTag };
|
||||
}
|
@ -1,60 +0,0 @@
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
const per_page = 99;
|
||||
|
||||
export function listTagsFactory(params: { octokit: Octokit }) {
|
||||
const { octokit } = params;
|
||||
|
||||
const octokit_repo_listTags = async (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
per_page: number;
|
||||
page: number;
|
||||
}) => {
|
||||
return octokit.repos.listTags(params);
|
||||
};
|
||||
|
||||
async function* listTags(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}): AsyncGenerator<string> {
|
||||
const { owner, repo } = params;
|
||||
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const resp = await octokit_repo_listTags({
|
||||
owner,
|
||||
repo,
|
||||
per_page,
|
||||
page: page++
|
||||
});
|
||||
|
||||
for (const branch of resp.data.map(({ name }) => name)) {
|
||||
yield branch;
|
||||
}
|
||||
|
||||
if (resp.data.length < 99) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the same "latest" tag as deno.land/x, not actually the latest though */
|
||||
async function getLatestTag(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}): Promise<string | undefined> {
|
||||
const { owner, repo } = params;
|
||||
|
||||
const itRes = await listTags({ owner, repo }).next();
|
||||
|
||||
if (itRes.done) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return itRes.value;
|
||||
}
|
||||
|
||||
return { listTags, getLatestTag };
|
||||
}
|
@ -15,7 +15,9 @@ export async function getIsPrettierAvailable(): Promise<boolean> {
|
||||
return getIsPrettierAvailable.cache;
|
||||
}
|
||||
|
||||
const nodeModulesBinDirPath = getNodeModulesBinDirPath();
|
||||
const nodeModulesBinDirPath = getNodeModulesBinDirPath({
|
||||
packageJsonFilePath: undefined
|
||||
});
|
||||
|
||||
const prettierBinPath = pathJoin(nodeModulesBinDirPath, "prettier");
|
||||
|
||||
@ -50,10 +52,26 @@ export async function getPrettier(): Promise<PrettierAndConfigHash> {
|
||||
// So we do a sketchy eval to bypass ncc.
|
||||
// We make sure to only do that when linking, otherwise we import properly.
|
||||
if (readThisNpmPackageVersion().startsWith("0.0.0")) {
|
||||
eval(
|
||||
`${symToStr({ prettier })} = require("${pathResolve(pathJoin(getNodeModulesBinDirPath(), "..", "prettier"))}")`
|
||||
const prettierDirPath = pathResolve(
|
||||
pathJoin(
|
||||
getNodeModulesBinDirPath({ packageJsonFilePath: undefined }),
|
||||
"..",
|
||||
"prettier"
|
||||
)
|
||||
);
|
||||
|
||||
const isCJS = typeof module !== "undefined" && module.exports;
|
||||
|
||||
if (isCJS) {
|
||||
eval(`${symToStr({ prettier })} = require("${prettierDirPath}")`);
|
||||
} else {
|
||||
prettier = await new Promise(_resolve => {
|
||||
eval(
|
||||
`import("file:///${pathJoin(prettierDirPath, "index.mjs").replace(/\\/g, "/")}").then(prettier => _resolve(prettier))`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
assert(!is<undefined>(prettier));
|
||||
|
||||
break import_prettier;
|
||||
@ -64,7 +82,7 @@ export async function getPrettier(): Promise<PrettierAndConfigHash> {
|
||||
|
||||
const configHash = await (async () => {
|
||||
const configFilePath = await prettier.resolveConfigFile(
|
||||
pathJoin(getNodeModulesBinDirPath(), "..")
|
||||
pathJoin(getNodeModulesBinDirPath({ packageJsonFilePath: undefined }), "..")
|
||||
);
|
||||
|
||||
if (configFilePath === null) {
|
||||
|
@ -1,39 +0,0 @@
|
||||
import { PassThrough, Readable } from "stream";
|
||||
|
||||
export default function tee(input: Readable) {
|
||||
const a = new PassThrough();
|
||||
const b = new PassThrough();
|
||||
|
||||
let aFull = false;
|
||||
let bFull = false;
|
||||
|
||||
a.setMaxListeners(Infinity);
|
||||
|
||||
a.on("drain", () => {
|
||||
aFull = false;
|
||||
if (!aFull && !bFull) input.resume();
|
||||
});
|
||||
b.on("drain", () => {
|
||||
bFull = false;
|
||||
if (!aFull && !bFull) input.resume();
|
||||
});
|
||||
|
||||
input.on("error", e => {
|
||||
a.emit("error", e);
|
||||
b.emit("error", e);
|
||||
});
|
||||
|
||||
input.on("data", chunk => {
|
||||
aFull = !a.write(chunk);
|
||||
bFull = !b.write(chunk);
|
||||
|
||||
if (aFull || bFull) input.pause();
|
||||
});
|
||||
|
||||
input.on("end", () => {
|
||||
a.end();
|
||||
b.end();
|
||||
});
|
||||
|
||||
return [a, b] as const;
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Concatenate the string fragments and interpolated values
|
||||
* to get a single string.
|
||||
*/
|
||||
function populateTemplate(strings: TemplateStringsArray, ...args: unknown[]) {
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
let lastStringLineLength = 0;
|
||||
if (strings[i]) {
|
||||
chunks.push(strings[i]);
|
||||
// remember last indent of the string portion
|
||||
lastStringLineLength = strings[i].split("\n").slice(-1)[0]?.length ?? 0;
|
||||
}
|
||||
if (args[i]) {
|
||||
// if the interpolation value has newlines, indent the interpolation values
|
||||
// using the last known string indent
|
||||
const chunk = String(args[i]).replace(
|
||||
/([\r?\n])/g,
|
||||
"$1" + " ".repeat(lastStringLineLength)
|
||||
);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift all lines left by the *smallest* indentation level,
|
||||
* and remove initial newline and all trailing spaces.
|
||||
*/
|
||||
export default function trimIndent(strings: TemplateStringsArray, ...args: any[]) {
|
||||
// Remove initial and final newlines
|
||||
let string = populateTemplate(strings, ...args)
|
||||
.replace(/^[\r\n]/, "")
|
||||
.replace(/\r?\n *$/, "");
|
||||
const dents =
|
||||
string
|
||||
.match(/^([ \t])+/gm)
|
||||
?.filter(s => /^\s+$/.test(s))
|
||||
?.map(s => s.length) ?? [];
|
||||
// No dents? no change required
|
||||
if (!dents || dents.length == 0) return string;
|
||||
const minDent = Math.min(...dents);
|
||||
// The min indentation is 0, no change needed
|
||||
if (!minDent) return string;
|
||||
const re = new RegExp(`^${" ".repeat(minDent)}`, "gm");
|
||||
const dedented = string.replace(re, "");
|
||||
return dedented;
|
||||
}
|
@ -10,5 +10,5 @@
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["initialize-account-theme/src"]
|
||||
"exclude": ["initialize-account-theme/multi-page-boilerplate"]
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ export async function command(params: { buildContext: BuildContext }) {
|
||||
await command({ buildContext });
|
||||
}
|
||||
|
||||
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
|
||||
const { hasBeenHandled } = await maybeDelegateCommandToCustomHandler({
|
||||
commandName: "update-kc-gen",
|
||||
buildContext
|
||||
});
|
||||
|
@ -769,6 +769,8 @@ export declare namespace Validators {
|
||||
export type PasswordPolicies = {
|
||||
/** The minimum length of the password */
|
||||
length?: number;
|
||||
/** The maximum length of the password */
|
||||
maxLength?: number;
|
||||
/** The minimum number of digits required in the password */
|
||||
digits?: number;
|
||||
/** The minimum number of lowercase characters required in the password */
|
||||
|
@ -1,6 +1,7 @@
|
||||
import type { JSX } from "keycloakify/tools/JSX";
|
||||
import { useEffect, useReducer, Fragment } from "react";
|
||||
import { useEffect, Fragment } from "react";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
|
||||
import type { KcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
import {
|
||||
useUserProfileForm,
|
||||
@ -89,7 +90,6 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps<
|
||||
{advancedMsg(attribute.annotations.inputHelperTextAfter)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{AfterField !== undefined && (
|
||||
<AfterField
|
||||
attribute={attribute}
|
||||
@ -106,6 +106,10 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps<
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{/* See: https://github.com/keycloak/keycloak/issues/38029 */}
|
||||
{kcContext.locale !== undefined && formFieldStates.find(x => x.attribute.name === "locale") === undefined && (
|
||||
<input type="hidden" name="locale" value={i18n.currentLanguage.languageTag} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -249,15 +253,7 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
|
||||
|
||||
const { msgStr } = i18n;
|
||||
|
||||
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
|
||||
|
||||
useEffect(() => {
|
||||
const passwordInputElement = document.getElementById(passwordInputId);
|
||||
|
||||
assert(passwordInputElement instanceof HTMLInputElement);
|
||||
|
||||
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
|
||||
}, [isPasswordRevealed]);
|
||||
const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
|
||||
|
||||
return (
|
||||
<div className={kcClsx("kcInputGroup")}>
|
||||
|
@ -509,6 +509,8 @@ function formStateSelector(params: { state: internal.State }): FormState {
|
||||
switch (error.source.name) {
|
||||
case "length":
|
||||
return hasLostFocusAtLeastOnce;
|
||||
case "maxLength":
|
||||
return hasLostFocusAtLeastOnce;
|
||||
case "digits":
|
||||
return hasLostFocusAtLeastOnce;
|
||||
case "lowerCase":
|
||||
@ -967,6 +969,34 @@ function createGetErrors(params: { kcContext: KcContextLike_useGetErrors }) {
|
||||
});
|
||||
}
|
||||
|
||||
check_password_policy_x: {
|
||||
const policyName = "maxLength";
|
||||
|
||||
const policy = passwordPolicies[policyName];
|
||||
|
||||
if (!policy) {
|
||||
break check_password_policy_x;
|
||||
}
|
||||
|
||||
const maxLength = policy;
|
||||
|
||||
if (value.length <= maxLength) {
|
||||
break check_password_policy_x;
|
||||
}
|
||||
|
||||
errors.push({
|
||||
advancedMsgArgs: [
|
||||
"invalidPasswordMaxLengthMessage" satisfies MessageKey_defaultSet,
|
||||
`${maxLength}`
|
||||
] as const,
|
||||
fieldIndex: undefined,
|
||||
source: {
|
||||
type: "passwordPolicy",
|
||||
name: policyName
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
check_password_policy_x: {
|
||||
const policyName = "digits";
|
||||
|
||||
|
@ -31,10 +31,10 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(
|
||||
(() => {
|
||||
let html = message.summary;
|
||||
let html = message.summary?.trim();
|
||||
|
||||
if (requiredActions) {
|
||||
html += "<b>";
|
||||
html += " <b>";
|
||||
|
||||
html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(", ");
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import type { JSX } from "keycloakify/tools/JSX";
|
||||
import { useState, useEffect, useReducer } from "react";
|
||||
import { useState } from "react";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
@ -200,15 +200,7 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
|
||||
|
||||
const { msgStr } = i18n;
|
||||
|
||||
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
|
||||
|
||||
useEffect(() => {
|
||||
const passwordInputElement = document.getElementById(passwordInputId);
|
||||
|
||||
assert(passwordInputElement instanceof HTMLInputElement);
|
||||
|
||||
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
|
||||
}, [isPasswordRevealed]);
|
||||
const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
|
||||
|
||||
return (
|
||||
<div className={kcClsx("kcInputGroup")}>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
@ -17,6 +17,8 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
|
||||
|
||||
const { msg, msgStr } = i18n;
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
return (
|
||||
<Template
|
||||
kcContext={kcContext}
|
||||
@ -26,7 +28,16 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
|
||||
displayMessage={!messagesPerField.existsError("totp")}
|
||||
headerNode={msg("doLogIn")}
|
||||
>
|
||||
<form id="kc-otp-login-form" className={kcClsx("kcFormClass")} action={url.loginAction} method="post">
|
||||
<form
|
||||
id="kc-otp-login-form"
|
||||
className={kcClsx("kcFormClass")}
|
||||
action={url.loginAction}
|
||||
onSubmit={() => {
|
||||
setIsSubmitting(true);
|
||||
return true;
|
||||
}}
|
||||
method="post"
|
||||
>
|
||||
{otpLogin.userOtpCredentials.length > 1 && (
|
||||
<div className={kcClsx("kcFormGroupClass")}>
|
||||
<div className={kcClsx("kcInputWrapperClass")}>
|
||||
@ -94,6 +105,7 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
|
||||
id="kc-login"
|
||||
type="submit"
|
||||
value={msgStr("doLogIn")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,8 +1,8 @@
|
||||
import type { JSX } from "keycloakify/tools/JSX";
|
||||
import { useState, useEffect, useReducer } from "react";
|
||||
import { useState } from "react";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
|
||||
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import type { KcContext } from "../KcContext";
|
||||
@ -107,15 +107,7 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
|
||||
|
||||
const { msgStr } = i18n;
|
||||
|
||||
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
|
||||
|
||||
useEffect(() => {
|
||||
const passwordInputElement = document.getElementById(passwordInputId);
|
||||
|
||||
assert(passwordInputElement instanceof HTMLInputElement);
|
||||
|
||||
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
|
||||
}, [isPasswordRevealed]);
|
||||
const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
|
||||
|
||||
return (
|
||||
<div className={kcClsx("kcInputGroup")}>
|
||||
|
@ -1,7 +1,6 @@
|
||||
import type { JSX } from "keycloakify/tools/JSX";
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import type { KcContext } from "../KcContext";
|
||||
@ -146,15 +145,7 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
|
||||
|
||||
const { msgStr } = i18n;
|
||||
|
||||
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
|
||||
|
||||
useEffect(() => {
|
||||
const passwordInputElement = document.getElementById(passwordInputId);
|
||||
|
||||
assert(passwordInputElement instanceof HTMLInputElement);
|
||||
|
||||
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
|
||||
}, [isPasswordRevealed]);
|
||||
const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
|
||||
|
||||
return (
|
||||
<div className={kcClsx("kcInputGroup")}>
|
||||
|
@ -98,7 +98,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
|
||||
defaultValue={login.username ?? ""}
|
||||
type="text"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
autoComplete="username"
|
||||
aria-invalid={messagesPerField.existsError("username")}
|
||||
/>
|
||||
{messagesPerField.existsError("username") && (
|
||||
|
45
src/tools/useIsPasswordRevealed.ts
Normal file
45
src/tools/useIsPasswordRevealed.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
|
||||
/**
|
||||
* Initially false, state that enables to dynamically control if
|
||||
* the type of a password input is "password" (false) or "text" (true).
|
||||
*/
|
||||
export function useIsPasswordRevealed(params: { passwordInputId: string }) {
|
||||
const { passwordInputId } = params;
|
||||
|
||||
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer(
|
||||
(isPasswordRevealed: boolean) => !isPasswordRevealed,
|
||||
false
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const passwordInputElement = document.getElementById(passwordInputId);
|
||||
|
||||
assert(passwordInputElement instanceof HTMLInputElement);
|
||||
|
||||
const type = isPasswordRevealed ? "text" : "password";
|
||||
|
||||
passwordInputElement.type = type;
|
||||
|
||||
const observer = new MutationObserver(mutations => {
|
||||
mutations.forEach(mutation => {
|
||||
if (mutation.attributeName !== "type") {
|
||||
return;
|
||||
}
|
||||
if (passwordInputElement.type === type) {
|
||||
return;
|
||||
}
|
||||
passwordInputElement.type = type;
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(passwordInputElement, { attributes: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [isPasswordRevealed]);
|
||||
|
||||
return { isPasswordRevealed, toggleIsPasswordRevealed };
|
||||
}
|
@ -155,8 +155,9 @@ export function keycloakify(params: keycloakify.Params) {
|
||||
{
|
||||
const isJavascriptFile = id.endsWith(".js") || id.endsWith(".jsx");
|
||||
const isTypeScriptFile = id.endsWith(".ts") || id.endsWith(".tsx");
|
||||
const isSvelteFile = id.endsWith(".svelte");
|
||||
|
||||
if (!isTypeScriptFile && !isJavascriptFile) {
|
||||
if (!isTypeScriptFile && !isJavascriptFile && !isSvelteFile) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -46,7 +46,7 @@ export const WithRequiredActions: Story = {
|
||||
kcContext={{
|
||||
messageHeader: "Message header",
|
||||
message: {
|
||||
summary: "Required actions: "
|
||||
summary: "Required actions:"
|
||||
},
|
||||
requiredActions: ["CONFIGURE_TOTP", "UPDATE_PROFILE", "VERIFY_EMAIL", "CUSTOM_ACTION"],
|
||||
"x-keycloakify": {
|
||||
|
@ -62,3 +62,22 @@ export const WithPasswordConfirmError: Story = {
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
||||
/**
|
||||
* WithAppInitiatedAction:
|
||||
* - Purpose: Tests when the update password action was triggered by an app.
|
||||
* - Scenario: Simulates the case where the user presses a 'change password' button in an app and is redirected to Keycloak to change it.
|
||||
* - Key Aspect: Ensures the 'Cancel' button is shown correctly, which displays only when the action is app initiated.
|
||||
*/
|
||||
export const WithAppInitiatedAction: Story = {
|
||||
render: () => (
|
||||
<KcPageStory
|
||||
kcContext={{
|
||||
url: {
|
||||
loginAction: "/mock-login-action"
|
||||
},
|
||||
isAppInitiatedAction: true
|
||||
}}
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
@ -17,5 +17,5 @@
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["../src", "."],
|
||||
"exclude": ["../src/bin/initialize-account-theme/src"]
|
||||
"exclude": ["../src/bin/initialize-account-theme/multi-page-boilerplate"]
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user