Compare commits
4 Commits
angular_in
...
v10.1.5
Author | SHA1 | Date | |
---|---|---|---|
645503119b | |||
804ef8cec6 | |||
85954be93d | |||
9a03f37fe3 |
@ -259,37 +259,6 @@
|
||||
"code",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "uchar",
|
||||
"name": "Omid",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/5172296?v=4",
|
||||
"profile": "https://www.linkedin.com/in/oes-rioniz/",
|
||||
"contributions": [
|
||||
"test",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "kathari00",
|
||||
"name": "Katharina Eiserfey",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/42547712?v=4",
|
||||
"profile": "https://github.com/kathari00",
|
||||
"contributions": [
|
||||
"code",
|
||||
"test",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "luca-peruzzo",
|
||||
"name": "Luca Peruzzo",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/69015314?v=4",
|
||||
"profile": "https://github.com/luca-peruzzo",
|
||||
"contributions": [
|
||||
"code",
|
||||
"test"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
38
.github/workflows/ci.yaml
vendored
38
.github/workflows/ci.yaml
vendored
@ -3,6 +3,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- v10
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@ -35,21 +36,21 @@ jobs:
|
||||
- run: npm run build
|
||||
- run: npm run test
|
||||
|
||||
storybook:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
- uses: bahmutov/npm-install@v1
|
||||
- run: npm run build-storybook
|
||||
- run: git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${{github.repository}}.git
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: npx -y -p gh-pages@3.1.0 gh-pages -d ./storybook-static -u "github-actions-bot <actions@github.com>"
|
||||
#storybook:
|
||||
# runs-on: ubuntu-latest
|
||||
# if: github.event_name == 'push'
|
||||
# needs: test
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '18'
|
||||
# - uses: bahmutov/npm-install@v1
|
||||
# - run: npm run build-storybook
|
||||
# - run: git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${{github.repository}}.git
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# - run: npx -y -p gh-pages@3.1.0 gh-pages -d ./storybook-static -u "github-actions-bot <actions@github.com>"
|
||||
|
||||
check_if_version_upgraded:
|
||||
name: Check if version upgrade
|
||||
@ -112,7 +113,7 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org/
|
||||
- uses: bahmutov/npm-install@v1
|
||||
- run: npm run build
|
||||
- run: npx -y -p denoify@1.6.13 enable_short_npm_import_path
|
||||
- run: npx -y -p denoify@1.6.12 enable_short_npm_import_path
|
||||
env:
|
||||
DRY_RUN: "0"
|
||||
- uses: garronej/ts-ci@v2.1.2
|
||||
@ -128,10 +129,7 @@ jobs:
|
||||
echo "Can't publish on NPM, You must first create a secret called NPM_TOKEN that contains your NPM auth token. https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets"
|
||||
false
|
||||
fi
|
||||
EXTRA_ARGS=""
|
||||
if [ "$IS_PRE_RELEASE" = "true" ]; then
|
||||
EXTRA_ARGS="--tag next"
|
||||
fi
|
||||
EXTRA_ARGS="--tag v10"
|
||||
npm publish $EXTRA_ARGS
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -49,7 +49,7 @@ jspm_packages
|
||||
.idea
|
||||
|
||||
/src/login/i18n/messages_defaultSet/
|
||||
/src/account/i18n/
|
||||
/src/account/i18n/messages_defaultSet/
|
||||
|
||||
# VS Code devcontainers
|
||||
.devcontainer
|
||||
|
@ -1,3 +1,3 @@
|
||||
Looking to contribute? Thank you! PR are more than welcome.
|
||||
|
||||
Please refers to [this documentation page](https://docs.keycloakify.dev/faq-and-help/contributing) that will help you get started.
|
||||
Please refers to [this documentation page](https://docs.keycloakify.dev/contributing) that will help you get started.
|
||||
|
@ -41,7 +41,7 @@
|
||||
<img width="400" src="https://github.com/user-attachments/assets/6bf3bef9-00b0-4460-97b9-0d2da8500798">
|
||||
</p>
|
||||
|
||||
Keycloakify is fully compatible with Keycloak from version 11 to 26...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
|
||||
Keycloakify is fully compatible with Keycloak from version 11 to 25...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
|
||||
|
||||
## Sponsors
|
||||
|
||||
@ -132,11 +132,6 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/oliviergoulet5"><img src="https://avatars.githubusercontent.com/u/17685861?v=4?s=100" width="100px;" alt="Olivier Goulet"/><br /><sub><b>Olivier Goulet</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=oliviergoulet5" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/liamlows"><img src="https://avatars.githubusercontent.com/u/1365914?v=4?s=100" width="100px;" alt="Liam Lowsley-Williams"/><br /><sub><b>Liam Lowsley-Williams</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=liamlows" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=liamlows" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.linkedin.com/in/oes-rioniz/"><img src="https://avatars.githubusercontent.com/u/5172296?v=4?s=100" width="100px;" alt="Omid"/><br /><sub><b>Omid</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=uchar" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=uchar" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kathari00"><img src="https://avatars.githubusercontent.com/u/42547712?v=4?s=100" width="100px;" alt="Katharina Eiserfey"/><br /><sub><b>Katharina Eiserfey</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kathari00" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=kathari00" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=kathari00" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/luca-peruzzo"><img src="https://avatars.githubusercontent.com/u/69015314?v=4?s=100" width="100px;" alt="Luca Peruzzo"/><br /><sub><b>Luca Peruzzo</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=luca-peruzzo" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=luca-peruzzo" title="Tests">⚠️</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
24
package.json
24
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "keycloakify",
|
||||
"version": "11.2.10",
|
||||
"description": "Framework to create custom Keycloak UIs",
|
||||
"version": "10.1.5",
|
||||
"description": "Create Keycloak themes using React",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/keycloakify/keycloakify.git"
|
||||
@ -62,42 +62,33 @@
|
||||
],
|
||||
"homepage": "https://www.keycloakify.dev",
|
||||
"dependencies": {
|
||||
"tsafe": "^1.7.5"
|
||||
"tsafe": "^1.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.5",
|
||||
"@babel/generator": "^7.24.5",
|
||||
"@babel/parser": "^7.24.5",
|
||||
"@babel/preset-env": "7.24.8",
|
||||
"@babel/types": "^7.24.5",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@keycloakify/angular": "^0.0.1-rc.19",
|
||||
"@octokit/rest": "^20.1.1",
|
||||
"@storybook/addon-a11y": "^6.5.16",
|
||||
"@storybook/builder-webpack5": "^6.5.13",
|
||||
"@storybook/manager-webpack5": "^6.5.13",
|
||||
"@storybook/react": "^6.5.13",
|
||||
"eslint-plugin-storybook": "^0.6.7",
|
||||
"@types/babel__generator": "^7.6.4",
|
||||
"@types/dompurify": "^2.0.0",
|
||||
"@types/make-fetch-happen": "^10.0.1",
|
||||
"@types/minimist": "^1.2.2",
|
||||
"@types/node": "^18.15.3",
|
||||
"@types/properties-parser": "^0.3.3",
|
||||
"@types/react": "^18.0.35",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"babel-loader": "9.1.3",
|
||||
"chalk": "^4.1.2",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"chokidar-cli": "^3.0.0",
|
||||
"cli-select": "^1.1.2",
|
||||
"dompurify": "^3.1.6",
|
||||
"eslint-plugin-storybook": "^0.6.7",
|
||||
"evt": "^2.5.7",
|
||||
"html-entities": "^2.5.2",
|
||||
"husky": "^4.3.8",
|
||||
"isomorphic-dompurify": "^2.15.0",
|
||||
"lint-staged": "^11.0.0",
|
||||
"magic-string": "^0.30.7",
|
||||
"make-fetch-happen": "^11.0.3",
|
||||
@ -112,13 +103,12 @@
|
||||
"termost": "^v0.12.1",
|
||||
"tsc-alias": "^1.8.10",
|
||||
"tss-react": "^4.9.10",
|
||||
"tsx": "^4.15.5",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^5.2.11",
|
||||
"vitest": "^1.6.0",
|
||||
"webpack": "5.93.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.17.10"
|
||||
"zod": "^3.17.10",
|
||||
"evt": "^2.5.7",
|
||||
"tsx": "^4.15.5"
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,10 @@
|
||||
import { run } from "./shared/run";
|
||||
import * as child_process from "child_process";
|
||||
|
||||
run("yarn build");
|
||||
run("npx build-storybook");
|
||||
|
||||
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
|
||||
console.log(`$ ${command}`);
|
||||
|
||||
child_process.execSync(command, { stdio: "inherit", ...options });
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import * as child_process from "child_process";
|
||||
import * as fs from "fs";
|
||||
import { join } from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
@ -5,8 +6,6 @@ import { transformCodebase } from "../../src/bin/tools/transformCodebase";
|
||||
import { createPublicKeycloakifyDevResourcesDir } from "./createPublicKeycloakifyDevResourcesDir";
|
||||
import { createAccountV1Dir } from "./createAccountV1Dir";
|
||||
import chalk from "chalk";
|
||||
import { run } from "../shared/run";
|
||||
import { vendorFrontendDependencies } from "./vendorFrontendDependencies";
|
||||
|
||||
(async () => {
|
||||
console.log(chalk.cyan("Building Keycloakify..."));
|
||||
@ -89,7 +88,6 @@ import { vendorFrontendDependencies } from "./vendorFrontendDependencies";
|
||||
|
||||
run(`npx tsc -p ${join("src", "tsconfig.json")}`);
|
||||
run(`npx tsc-alias -p ${join("src", "tsconfig.json")}`);
|
||||
vendorFrontendDependencies({ distDirPath: join(process.cwd(), "dist") });
|
||||
|
||||
if (fs.existsSync(join("dist", "vite-plugin", "index.original.js"))) {
|
||||
fs.renameSync(
|
||||
@ -166,6 +164,12 @@ import { vendorFrontendDependencies } from "./vendorFrontendDependencies";
|
||||
);
|
||||
})();
|
||||
|
||||
function run(command: string) {
|
||||
console.log(chalk.grey(`$ ${command}`));
|
||||
|
||||
child_process.execSync(command, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
function patchDeprecatedBufferApiUsage(filePath: string) {
|
||||
const before = fs.readFileSync(filePath).toString("utf8");
|
||||
|
||||
|
@ -1,101 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import {
|
||||
join as pathJoin,
|
||||
relative as pathRelative,
|
||||
basename as pathBasename,
|
||||
dirname as pathDirname
|
||||
} from "path";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { run } from "../shared/run";
|
||||
import { cacheDirPath as cacheDirPath_base } from "../shared/cacheDirPath";
|
||||
|
||||
export function vendorFrontendDependencies(params: { distDirPath: string }) {
|
||||
const { distDirPath } = params;
|
||||
|
||||
const vendorDirPath = pathJoin(distDirPath, "tools", "vendor");
|
||||
const cacheDirPath = pathJoin(cacheDirPath_base, "vendorFrontendDependencies");
|
||||
|
||||
const extraBundleFileBasenames = new Set<string>();
|
||||
|
||||
fs.readdirSync(vendorDirPath)
|
||||
.filter(fileBasename => fileBasename.endsWith(".js"))
|
||||
.map(fileBasename => pathJoin(vendorDirPath, fileBasename))
|
||||
.forEach(filePath => {
|
||||
{
|
||||
const mapFilePath = `${filePath}.map`;
|
||||
|
||||
if (fs.existsSync(mapFilePath)) {
|
||||
fs.unlinkSync(mapFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(cacheDirPath)) {
|
||||
fs.mkdirSync(cacheDirPath, { recursive: true });
|
||||
}
|
||||
|
||||
const webpackConfigJsFilePath = pathJoin(cacheDirPath, "webpack.config.js");
|
||||
const webpackOutputDirPath = pathJoin(cacheDirPath, "webpack_output");
|
||||
const webpackOutputFilePath = pathJoin(webpackOutputDirPath, "index.js");
|
||||
|
||||
fs.writeFileSync(
|
||||
webpackConfigJsFilePath,
|
||||
Buffer.from(
|
||||
[
|
||||
`const path = require('path');`,
|
||||
``,
|
||||
`module.exports = {`,
|
||||
` mode: 'production',`,
|
||||
` entry: '${filePath}',`,
|
||||
` output: {`,
|
||||
` path: '${webpackOutputDirPath}',`,
|
||||
` filename: '${pathBasename(webpackOutputFilePath)}',`,
|
||||
` libraryTarget: 'module',`,
|
||||
` },`,
|
||||
` target: "web",`,
|
||||
` module: {`,
|
||||
` rules: [`,
|
||||
` {`,
|
||||
` test: /\.js$/,`,
|
||||
` use: {`,
|
||||
` loader: 'babel-loader',`,
|
||||
` options: {`,
|
||||
` presets: ['@babel/preset-env'],`,
|
||||
` }`,
|
||||
` }`,
|
||||
` }`,
|
||||
` ]`,
|
||||
` },`,
|
||||
` experiments: {`,
|
||||
` outputModule: true`,
|
||||
` }`,
|
||||
`};`
|
||||
].join("\n")
|
||||
)
|
||||
);
|
||||
|
||||
run(`npx webpack --config ${webpackConfigJsFilePath}`);
|
||||
|
||||
fs.readdirSync(webpackOutputDirPath)
|
||||
.filter(fileBasename => !fileBasename.endsWith(".txt"))
|
||||
.map(fileBasename => pathJoin(webpackOutputDirPath, fileBasename))
|
||||
.forEach(bundleFilePath => {
|
||||
assert(bundleFilePath.endsWith(".js"));
|
||||
|
||||
if (pathBasename(bundleFilePath) === "index.js") {
|
||||
fs.renameSync(webpackOutputFilePath, filePath);
|
||||
} else {
|
||||
const bundleFileBasename = pathBasename(bundleFilePath);
|
||||
|
||||
assert(!extraBundleFileBasenames.has(bundleFileBasename));
|
||||
extraBundleFileBasenames.add(bundleFileBasename);
|
||||
|
||||
fs.renameSync(
|
||||
bundleFilePath,
|
||||
pathJoin(pathDirname(filePath), bundleFileBasename)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
fs.rmSync(webpackOutputDirPath, { recursive: true });
|
||||
});
|
||||
}
|
@ -6,7 +6,6 @@ import chalk from "chalk";
|
||||
import { Deferred } from "evt/tools/Deferred";
|
||||
import { assert } from "tsafe/assert";
|
||||
import { is } from "tsafe/is";
|
||||
import { run } from "./shared/run";
|
||||
|
||||
(async () => {
|
||||
{
|
||||
@ -85,3 +84,9 @@ import { run } from "./shared/run";
|
||||
|
||||
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
|
||||
})();
|
||||
|
||||
function run(command: string) {
|
||||
console.log(chalk.grey(`$ ${command}`));
|
||||
|
||||
return child_process.execSync(command, { stdio: "inherit" });
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import "minimal-polyfills/Object.fromEntries";
|
||||
import * as fs from "fs";
|
||||
import {
|
||||
join as pathJoin,
|
||||
@ -12,8 +13,7 @@ import { downloadKeycloakDefaultTheme } from "./shared/downloadKeycloakDefaultTh
|
||||
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
|
||||
import { deepAssign } from "../src/tools/deepAssign";
|
||||
import { THEME_TYPES } from "../src/bin/shared/constants";
|
||||
import { transformCodebase } from "../src/bin/tools/transformCodebase";
|
||||
import propertiesParser from "properties-parser";
|
||||
const propertiesParser: any = require("properties-parser");
|
||||
|
||||
if (require.main === module) {
|
||||
generateI18nMessages();
|
||||
@ -22,17 +22,6 @@ if (require.main === module) {
|
||||
async function generateI18nMessages() {
|
||||
const thisCodebaseRootDirPath = getThisCodebaseRootDirPath();
|
||||
|
||||
const accountI18nDirPath = pathJoin(
|
||||
thisCodebaseRootDirPath,
|
||||
"src",
|
||||
"account",
|
||||
"i18n"
|
||||
);
|
||||
|
||||
if (fs.existsSync(accountI18nDirPath)) {
|
||||
fs.rmSync(accountI18nDirPath, { recursive: true });
|
||||
}
|
||||
|
||||
type Dictionary = { [idiomId: string]: string };
|
||||
|
||||
const record: { [themeType: string]: { [language: string]: Dictionary } } = {};
|
||||
@ -150,26 +139,6 @@ async function generateI18nMessages() {
|
||||
"messages_defaultSet"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(messagesDirPath)) {
|
||||
fs.mkdirSync(messagesDirPath, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(messagesDirPath, "types.ts"),
|
||||
Buffer.from(
|
||||
[
|
||||
``,
|
||||
`export const languageTags = ${JSON.stringify(languages, null, 2)} as const;`,
|
||||
``,
|
||||
`export type LanguageTag = typeof languageTags[number];`,
|
||||
``,
|
||||
`export type MessageKey = keyof typeof import("./en")["default"];`,
|
||||
``
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
|
||||
const generatedFileHeader = [
|
||||
`//This code was automatically generated by running ${pathRelative(
|
||||
thisCodebaseRootDirPath,
|
||||
@ -233,18 +202,6 @@ async function generateI18nMessages() {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: pathJoin(thisCodebaseRootDirPath, "src", "login", "i18n"),
|
||||
destDirPath: accountI18nDirPath,
|
||||
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
|
||||
if (fileRelativePath.startsWith("messages_defaultSet")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const keycloakifyExtraMessages_login: Record<
|
||||
|
@ -1,8 +1,8 @@
|
||||
import * as child_process from "child_process";
|
||||
import * as fs from "fs";
|
||||
import { join } from "path";
|
||||
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
|
||||
import { crawl } from "../src/bin/tools/crawl";
|
||||
import { run } from "./shared/run";
|
||||
|
||||
{
|
||||
const dirPath = "node_modules";
|
||||
@ -47,3 +47,9 @@ run("yarn install", { cwd: join("..", starterName) });
|
||||
run(`npx tsx ${join("scripts", "link-in-app.ts")} ${starterName}`);
|
||||
|
||||
startRebuildOnSrcChange();
|
||||
|
||||
function run(command: string, options?: { cwd: string }) {
|
||||
console.log(`$ ${command}`);
|
||||
|
||||
child_process.execSync(command, { stdio: "inherit", ...options });
|
||||
}
|
||||
|
@ -1,9 +0,0 @@
|
||||
import { join as pathJoin } from "path";
|
||||
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
|
||||
|
||||
export const cacheDirPath = pathJoin(
|
||||
getThisCodebaseRootDirPath(),
|
||||
"node_modules",
|
||||
".cache",
|
||||
"scripts"
|
||||
);
|
@ -2,9 +2,8 @@ import { relative as pathRelative } from "path";
|
||||
import { downloadAndExtractArchive } from "../../src/bin/tools/downloadAndExtractArchive";
|
||||
import { getProxyFetchOptions } from "../../src/bin/tools/fetchProxyOptions";
|
||||
import { join as pathJoin } from "path";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import { cacheDirPath } from "./cacheDirPath";
|
||||
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
|
||||
const KEYCLOAK_VERSION = {
|
||||
FOR_LOGIN_THEME: "25.0.4",
|
||||
@ -23,7 +22,12 @@ export async function downloadKeycloakDefaultTheme(params: {
|
||||
|
||||
const { extractedDirPath } = await downloadAndExtractArchive({
|
||||
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
|
||||
cacheDirPath,
|
||||
cacheDirPath: pathJoin(
|
||||
getThisCodebaseRootDirPath(),
|
||||
"node_modules",
|
||||
".cache",
|
||||
"scripts"
|
||||
),
|
||||
fetchOptions: getProxyFetchOptions({
|
||||
npmConfigGetCwd: getThisCodebaseRootDirPath()
|
||||
}),
|
||||
|
@ -1,8 +0,0 @@
|
||||
import * as child_process from "child_process";
|
||||
import chalk from "chalk";
|
||||
|
||||
export function run(command: string, options?: { cwd: string }) {
|
||||
console.log(chalk.grey(`$ ${command}`));
|
||||
|
||||
child_process.execSync(command, { stdio: "inherit", ...options });
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import * as child_process from "child_process";
|
||||
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
|
||||
import { run } from "./shared/run";
|
||||
|
||||
(async () => {
|
||||
run("yarn build");
|
||||
@ -19,3 +18,9 @@ import { run } from "./shared/run";
|
||||
|
||||
startRebuildOnSrcChange();
|
||||
})();
|
||||
|
||||
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
|
||||
console.log(`$ ${command}`);
|
||||
|
||||
child_process.execSync(command, { stdio: "inherit", ...options });
|
||||
}
|
||||
|
@ -3,8 +3,6 @@ import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "keycloakify/bin/shared/constants
|
||||
import { id } from "tsafe/id";
|
||||
import type { KcContext } from "./KcContext";
|
||||
import { BASE_URL } from "keycloakify/lib/BASE_URL";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import type { LanguageTag } from "keycloakify/account/i18n/messages_defaultSet/types";
|
||||
|
||||
const resourcesPath = `${BASE_URL}${WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES}/account`;
|
||||
|
||||
@ -40,53 +38,35 @@ export const kcContextCommonMock: KcContext.Common = {
|
||||
exists: () => false
|
||||
},
|
||||
locale: {
|
||||
supported: (
|
||||
[
|
||||
/* spell-checker: disable */
|
||||
["de", "Deutsch"],
|
||||
["no", "Norsk"],
|
||||
["ru", "Русский"],
|
||||
["sv", "Svenska"],
|
||||
["pt-BR", "Português (Brasil)"],
|
||||
["lt", "Lietuvių"],
|
||||
["en", "English"],
|
||||
["it", "Italiano"],
|
||||
["fr", "Français"],
|
||||
["zh-CN", "中文简体"],
|
||||
["es", "Español"],
|
||||
["cs", "Čeština"],
|
||||
["ja", "日本語"],
|
||||
["sk", "Slovenčina"],
|
||||
["pl", "Polski"],
|
||||
["ca", "Català"],
|
||||
["nl", "Nederlands"],
|
||||
["tr", "Türkçe"],
|
||||
["ar", "العربية"],
|
||||
["da", "Dansk"],
|
||||
["fi", "Suomi"],
|
||||
["hu", "Magyar"],
|
||||
["lv", "Latviešu"]
|
||||
/* spell-checker: enable */
|
||||
] as const
|
||||
).map(([languageTag, label]) => {
|
||||
{
|
||||
type Got = typeof languageTag;
|
||||
type Expected = LanguageTag;
|
||||
|
||||
type Missing = Exclude<Expected, Got>;
|
||||
type Unexpected = Exclude<Got, Expected>;
|
||||
|
||||
assert<Equals<Missing, never>>;
|
||||
assert<Equals<Unexpected, never>>;
|
||||
}
|
||||
|
||||
return {
|
||||
languageTag,
|
||||
label,
|
||||
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
|
||||
} as const;
|
||||
}),
|
||||
|
||||
supported: [
|
||||
/* spell-checker: disable */
|
||||
["de", "Deutsch"],
|
||||
["no", "Norsk"],
|
||||
["ru", "Русский"],
|
||||
["sv", "Svenska"],
|
||||
["pt-BR", "Português (Brasil)"],
|
||||
["lt", "Lietuvių"],
|
||||
["en", "English"],
|
||||
["it", "Italiano"],
|
||||
["fr", "Français"],
|
||||
["zh-CN", "中文简体"],
|
||||
["es", "Español"],
|
||||
["cs", "Čeština"],
|
||||
["ja", "日本語"],
|
||||
["sk", "Slovenčina"],
|
||||
["pl", "Polski"],
|
||||
["ca", "Català"],
|
||||
["nl", "Nederlands"],
|
||||
["tr", "Türkçe"]
|
||||
/* spell-checker: enable */
|
||||
].map(
|
||||
([languageTag, label]) =>
|
||||
({
|
||||
languageTag,
|
||||
label,
|
||||
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
|
||||
}) as const
|
||||
),
|
||||
currentLanguageTag: "en"
|
||||
},
|
||||
features: {
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
||||
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
|
||||
import { useSetClassName } from "keycloakify/tools/useSetClassName";
|
||||
import { useInitialize } from "keycloakify/account/Template.useInitialize";
|
||||
import type { TemplateProps } from "keycloakify/account/TemplateProps";
|
||||
import type { I18n } from "./i18n";
|
||||
import type { KcContext } from "./KcContext";
|
||||
@ -13,9 +13,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
|
||||
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
|
||||
|
||||
const { msg, msgStr, currentLanguage, enabledLanguages } = i18n;
|
||||
const { msg, msgStr, getChangeLocaleUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
|
||||
|
||||
const { url, features, realm, message, referrer } = kcContext;
|
||||
const { locale, url, features, realm, message, referrer } = kcContext;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = msgStr("accountManagementTitle");
|
||||
@ -31,9 +31,30 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
className: clsx("admin-console", "user", kcClsx("kcBodyClass"))
|
||||
});
|
||||
|
||||
const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss });
|
||||
useEffect(() => {
|
||||
const { currentLanguageTag } = locale ?? {};
|
||||
|
||||
if (!isReadyToRender) {
|
||||
if (currentLanguageTag === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = document.querySelector("html");
|
||||
assert(html !== null);
|
||||
html.lang = currentLanguageTag;
|
||||
}, []);
|
||||
|
||||
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
|
||||
componentOrHookName: "Template",
|
||||
hrefs: !doUseDefaultCss
|
||||
? []
|
||||
: [
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
|
||||
`${url.resourcesPath}/css/account.css`
|
||||
]
|
||||
});
|
||||
|
||||
if (!areAllStyleSheetsLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -49,16 +70,16 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
<div className="navbar-collapse navbar-collapse-1">
|
||||
<div className="container">
|
||||
<ul className="nav navbar-nav navbar-utility">
|
||||
{enabledLanguages.length > 1 && (
|
||||
{realm.internationalizationEnabled && (assert(locale !== undefined), true) && locale.supported.length > 1 && (
|
||||
<li>
|
||||
<div className="kc-dropdown" id="kc-locale-dropdown">
|
||||
<a href="#" id="kc-current-locale-link">
|
||||
{currentLanguage.label}
|
||||
{labelBySupportedLanguageTag[currentLanguageTag]}
|
||||
</a>
|
||||
<ul>
|
||||
{enabledLanguages.map(({ languageTag, label, href }) => (
|
||||
{locale.supported.map(({ languageTag }) => (
|
||||
<li key={languageTag} className="kc-dropdown-item">
|
||||
<a href={href}>{label}</a>
|
||||
<a href={getChangeLocaleUrl(languageTag)}>{labelBySupportedLanguageTag[languageTag]}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -127,7 +148,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
<span
|
||||
className="kc-feedback-text"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(message.summary)
|
||||
__html: message.summary
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,35 +0,0 @@
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
|
||||
import type { KcContext } from "keycloakify/account/KcContext";
|
||||
|
||||
export type KcContextLike = {
|
||||
url: {
|
||||
resourcesCommonPath: string;
|
||||
resourcesPath: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert<keyof KcContextLike extends keyof KcContext ? true : false>();
|
||||
assert<KcContext extends KcContextLike ? true : false>();
|
||||
|
||||
export function useInitialize(params: {
|
||||
kcContext: KcContextLike;
|
||||
doUseDefaultCss: boolean;
|
||||
}) {
|
||||
const { kcContext, doUseDefaultCss } = params;
|
||||
|
||||
const { url } = kcContext;
|
||||
|
||||
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
|
||||
componentOrHookName: "Template",
|
||||
hrefs: !doUseDefaultCss
|
||||
? []
|
||||
: [
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`,
|
||||
`${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`,
|
||||
`${url.resourcesPath}/css/account.css`
|
||||
]
|
||||
});
|
||||
|
||||
return { isReadyToRender: areAllStyleSheetsLoaded };
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ClassKey } from "keycloakify/account/lib/kcClsx";
|
||||
|
||||
export type TemplateProps<KcContext, I18n> = {
|
||||
kcContext: KcContext;
|
||||
@ -11,4 +10,17 @@ export type TemplateProps<KcContext, I18n> = {
|
||||
active: string;
|
||||
};
|
||||
|
||||
export type { ClassKey };
|
||||
export type ClassKey =
|
||||
| "kcHtmlClass"
|
||||
| "kcBodyClass"
|
||||
| "kcButtonClass"
|
||||
| "kcButtonPrimaryClass"
|
||||
| "kcButtonLargeClass"
|
||||
| "kcButtonDefaultClass"
|
||||
| "kcContentWrapperClass"
|
||||
| "kcFormClass"
|
||||
| "kcFormGroupClass"
|
||||
| "kcInputWrapperClass"
|
||||
| "kcLabelClass"
|
||||
| "kcInputClass"
|
||||
| "kcInputErrorMessageClass";
|
||||
|
6
src/account/i18n/GenericI18n.tsx
Normal file
6
src/account/i18n/GenericI18n.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import type { GenericI18n_noJsx } from "./i18n";
|
||||
|
||||
export type GenericI18n<MessageKey extends string> = GenericI18n_noJsx<MessageKey> & {
|
||||
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
|
||||
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
|
||||
};
|
250
src/account/i18n/i18n.tsx
Normal file
250
src/account/i18n/i18n.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import "keycloakify/tools/Object.fromEntries";
|
||||
import { assert } from "tsafe/assert";
|
||||
import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en";
|
||||
import { fetchMessages_defaultSet } from "./messages_defaultSet";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
|
||||
import { id } from "tsafe/id";
|
||||
|
||||
export type KcContextLike = {
|
||||
locale?: {
|
||||
currentLanguageTag: string;
|
||||
supported: { languageTag: string; url: string; label: string }[];
|
||||
};
|
||||
"x-keycloakify": {
|
||||
messages: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
assert<KcContext extends KcContextLike ? true : false>();
|
||||
|
||||
export type GenericI18n_noJsx<MessageKey extends string> = {
|
||||
/**
|
||||
* e.g: "en", "fr", "zh-CN"
|
||||
*
|
||||
* The current language
|
||||
*/
|
||||
currentLanguageTag: string;
|
||||
/**
|
||||
* Redirect to this url to change the language.
|
||||
* After reload currentLanguageTag === newLanguageTag
|
||||
*/
|
||||
getChangeLocaleUrl: (newLanguageTag: string) => string;
|
||||
/**
|
||||
* e.g. "en" => "English", "fr" => "Français", ...
|
||||
*
|
||||
* Used to render a select that enable user to switch language.
|
||||
* ex: https://user-images.githubusercontent.com/6702424/186044799-38801eec-4e89-483b-81dd-8e9233e8c0eb.png
|
||||
* */
|
||||
labelBySupportedLanguageTag: Record<string, string>;
|
||||
/**
|
||||
*
|
||||
* Examples assuming currentLanguageTag === "en"
|
||||
* {
|
||||
* en: {
|
||||
* "access-denied": "Access denied",
|
||||
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
|
||||
* "bar": "Bar {0}"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* msgStr("access-denied") === "Access denied"
|
||||
* msgStr("not-a-message-key") Throws an error
|
||||
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
|
||||
* msgStr("${bar}", "<strong>c</strong>") === "Bar <strong>XXX</strong>"
|
||||
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
|
||||
*/
|
||||
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
|
||||
/**
|
||||
* This is meant to be used when the key argument is variable, something that might have been configured by the user
|
||||
* in the Keycloak admin for example.
|
||||
*
|
||||
* Examples assuming currentLanguageTag === "en"
|
||||
* {
|
||||
* en: {
|
||||
* "access-denied": "Access denied",
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
|
||||
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
|
||||
*/
|
||||
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
|
||||
|
||||
/**
|
||||
* Initially the messages are in english (fallback language).
|
||||
* The translations in the current language are being fetched dynamically.
|
||||
* This property is true while the translations are being fetched.
|
||||
*/
|
||||
isFetchingTranslations: boolean;
|
||||
};
|
||||
|
||||
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
|
||||
|
||||
export function createGetI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag_themeDefined: {
|
||||
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
|
||||
}) {
|
||||
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>;
|
||||
|
||||
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
|
||||
|
||||
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
|
||||
|
||||
function getI18n(params: { kcContext: KcContextLike }): Result {
|
||||
const { kcContext } = params;
|
||||
|
||||
use_cache: {
|
||||
const cachedResult = cachedResultByKcContext.get(kcContext);
|
||||
|
||||
if (cachedResult === undefined) {
|
||||
break use_cache;
|
||||
}
|
||||
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocaleUrl" | "labelBySupportedLanguageTag"> = {
|
||||
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG,
|
||||
getChangeLocaleUrl: newLanguageTag => {
|
||||
const { locale } = kcContext;
|
||||
|
||||
assert(locale !== undefined, "Internationalization not enabled");
|
||||
|
||||
const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag);
|
||||
|
||||
assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`);
|
||||
|
||||
return targetSupportedLocale.url;
|
||||
},
|
||||
labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]))
|
||||
};
|
||||
|
||||
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
|
||||
messages_themeDefined:
|
||||
messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ??
|
||||
messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG] ??
|
||||
(() => {
|
||||
const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0];
|
||||
if (firstLanguageTag === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return messagesByLanguageTag_themeDefined[firstLanguageTag];
|
||||
})(),
|
||||
messages_fromKcServer: kcContext["x-keycloakify"].messages
|
||||
});
|
||||
|
||||
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === FALLBACK_LANGUAGE_TAG;
|
||||
|
||||
const result: Result = {
|
||||
i18n: {
|
||||
...partialI18n,
|
||||
...createI18nTranslationFunctions({
|
||||
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
|
||||
}),
|
||||
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
|
||||
},
|
||||
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
|
||||
? undefined
|
||||
: (async () => {
|
||||
const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag);
|
||||
|
||||
const i18n_currentLanguage: I18n = {
|
||||
...partialI18n,
|
||||
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
|
||||
isFetchingTranslations: false
|
||||
};
|
||||
|
||||
// NOTE: This promise.resolve is just because without it we TypeScript
|
||||
// gives a Variable 'result' is used before being assigned. error
|
||||
await Promise.resolve().then(() => {
|
||||
result.i18n = i18n_currentLanguage;
|
||||
result.prI18n_currentLanguage = undefined;
|
||||
});
|
||||
|
||||
return i18n_currentLanguage;
|
||||
})()
|
||||
};
|
||||
|
||||
cachedResultByKcContext.set(kcContext, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return { getI18n };
|
||||
}
|
||||
|
||||
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
|
||||
messages_themeDefined: Record<MessageKey_themeDefined, string> | undefined;
|
||||
messages_fromKcServer: Record<string, string>;
|
||||
}) {
|
||||
const { messages_themeDefined, messages_fromKcServer } = params;
|
||||
|
||||
function createI18nTranslationFunctions(params: {
|
||||
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
|
||||
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>, "msgStr" | "advancedMsgStr"> {
|
||||
const { messages_defaultSet_currentLanguage } = params;
|
||||
|
||||
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
|
||||
const { key, args } = props;
|
||||
|
||||
const message =
|
||||
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
|
||||
id<Record<string, string | undefined> | undefined>(messages_themeDefined)?.[key] ??
|
||||
id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
|
||||
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];
|
||||
|
||||
if (message === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startIndex = message
|
||||
.match(/{[0-9]+}/g)
|
||||
?.map(g => g.match(/{([0-9]+)}/)![1])
|
||||
.map(indexStr => parseInt(indexStr))
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
if (startIndex === undefined) {
|
||||
// No {0} in message (no arguments expected)
|
||||
return message;
|
||||
}
|
||||
|
||||
let messageWithArgsInjected = message;
|
||||
|
||||
args.forEach((arg, i) => {
|
||||
if (arg === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
messageWithArgsInjected = messageWithArgsInjected.replace(
|
||||
new RegExp(`\\{${i + startIndex}\\}`, "g"),
|
||||
arg.replace(/</g, "<").replace(/>/g, ">")
|
||||
);
|
||||
});
|
||||
|
||||
return messageWithArgsInjected;
|
||||
}
|
||||
|
||||
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
|
||||
const { key, args } = props;
|
||||
|
||||
const match = key.match(/^\$\{(.+)\}$/);
|
||||
|
||||
if (match === null) {
|
||||
return key;
|
||||
}
|
||||
|
||||
return resolveMsg({ key: match[1], args }) ?? key;
|
||||
}
|
||||
|
||||
return {
|
||||
msgStr: (key, ...args) => {
|
||||
const resolvedMessage = resolveMsg({ key, args });
|
||||
assert(resolvedMessage !== undefined, `Message with key "${key}" not found`);
|
||||
return resolvedMessage;
|
||||
},
|
||||
advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args })
|
||||
};
|
||||
}
|
||||
|
||||
return { createI18nTranslationFunctions };
|
||||
}
|
5
src/account/i18n/index.ts
Normal file
5
src/account/i18n/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { GenericI18n } from "./GenericI18n";
|
||||
import type { MessageKey_defaultSet, KcContextLike } from "./i18n";
|
||||
export type { MessageKey_defaultSet, KcContextLike };
|
||||
export type I18n = GenericI18n<MessageKey_defaultSet>;
|
||||
export { createUseI18n } from "./useI18n";
|
@ -1,49 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import { createGetI18n, type KcContextLike } from "../noJsx/getI18n";
|
||||
import type { GenericI18n_noJsx } from "../noJsx/GenericI18n_noJsx";
|
||||
import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n";
|
||||
import { GenericI18n } from "./GenericI18n";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
import type { GenericI18n } from "./GenericI18n";
|
||||
import type { LanguageTag as LanguageTag_defaultSet, MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";
|
||||
|
||||
export type ReturnTypeOfCreateUseI18n<MessageKey_themeDefined extends string, LanguageTag_notInDefaultSet extends string> = {
|
||||
useI18n: (params: { kcContext: KcContextLike }) => {
|
||||
i18n: GenericI18n<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
|
||||
};
|
||||
ofTypeI18n: GenericI18n<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
|
||||
};
|
||||
|
||||
export { KcContextLike };
|
||||
|
||||
export function createUseI18n<
|
||||
ThemeName extends string = string,
|
||||
MessageKey_themeDefined extends string = never,
|
||||
LanguageTag_notInDefaultSet extends string = never
|
||||
>(params: {
|
||||
extraLanguageTranslations: {
|
||||
[languageTag in LanguageTag_notInDefaultSet]: {
|
||||
label: string;
|
||||
getMessages: () => Promise<{ default: Record<MessageKey_defaultSet, string> }>;
|
||||
};
|
||||
};
|
||||
messagesByLanguageTag_themeDefined: Partial<{
|
||||
[languageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: {
|
||||
[key in MessageKey_themeDefined]: string | Record<ThemeName, string>;
|
||||
};
|
||||
}>;
|
||||
}): ReturnTypeOfCreateUseI18n<MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
|
||||
const { extraLanguageTranslations, messagesByLanguageTag_themeDefined } = params;
|
||||
|
||||
type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet;
|
||||
|
||||
export function createUseI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag: {
|
||||
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
|
||||
}) {
|
||||
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
|
||||
|
||||
type I18n = GenericI18n<MessageKey, LanguageTag>;
|
||||
|
||||
type Result = { i18n: I18n };
|
||||
type I18n = GenericI18n<MessageKey>;
|
||||
|
||||
const { withJsx } = (() => {
|
||||
const cache = new WeakMap<GenericI18n_noJsx<MessageKey, LanguageTag>, GenericI18n<MessageKey, LanguageTag>>();
|
||||
const cache = new WeakMap<GenericI18n_noJsx<MessageKey>, GenericI18n<MessageKey>>();
|
||||
|
||||
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
|
||||
const { htmlString, msgKey } = params;
|
||||
@ -51,13 +19,13 @@ export function createUseI18n<
|
||||
<div
|
||||
data-kc-msg={msgKey}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(htmlString)
|
||||
__html: htmlString
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey, LanguageTag>): I18n {
|
||||
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey>): I18n {
|
||||
use_cache: {
|
||||
const i18n = cache.get(i18n_noJsx);
|
||||
|
||||
@ -95,9 +63,9 @@ export function createUseI18n<
|
||||
(styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement);
|
||||
}
|
||||
|
||||
const { getI18n } = createGetI18n({ extraLanguageTranslations, messagesByLanguageTag_themeDefined });
|
||||
const { getI18n } = createGetI18n(messagesByLanguageTag);
|
||||
|
||||
function useI18n(params: { kcContext: KcContextLike }): Result {
|
||||
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
|
||||
const { kcContext } = params;
|
||||
|
||||
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
|
@ -1,3 +1,3 @@
|
||||
export type { ExtendKcContext } from "keycloakify/account/KcContext";
|
||||
export type { ClassKey } from "keycloakify/account/TemplateProps";
|
||||
export { i18nBuilder, type MessageKey_defaultSet } from "keycloakify/account/i18n";
|
||||
export { createUseI18n } from "keycloakify/account/i18n";
|
||||
|
@ -1,19 +1,5 @@
|
||||
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
|
||||
|
||||
export type ClassKey =
|
||||
| "kcHtmlClass"
|
||||
| "kcBodyClass"
|
||||
| "kcButtonClass"
|
||||
| "kcButtonPrimaryClass"
|
||||
| "kcButtonLargeClass"
|
||||
| "kcButtonDefaultClass"
|
||||
| "kcContentWrapperClass"
|
||||
| "kcFormClass"
|
||||
| "kcFormGroupClass"
|
||||
| "kcInputWrapperClass"
|
||||
| "kcLabelClass"
|
||||
| "kcInputClass"
|
||||
| "kcInputErrorMessageClass";
|
||||
import type { ClassKey } from "keycloakify/account/TemplateProps";
|
||||
|
||||
export const { getKcClsx } = createGetKcClsx<ClassKey>({
|
||||
defaultClasses: {
|
||||
@ -34,4 +20,6 @@ export const { getKcClsx } = createGetKcClsx<ClassKey>({
|
||||
}
|
||||
});
|
||||
|
||||
export type { ClassKey };
|
||||
|
||||
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import { getKcClsx } from "keycloakify/account/lib/kcClsx";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import type { PageProps } from "keycloakify/account/pages/PageProps";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
@ -160,7 +159,7 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.get("totp"))
|
||||
__html: messagesPerField.get("totp")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -191,7 +190,7 @@ export default function Totp(props: PageProps<Extract<KcContext, { pageId: "totp
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.get("userLabel"))
|
||||
__html: messagesPerField.get("userLabel")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,12 +1,5 @@
|
||||
import { i18nBuilder } from "keycloakify/account";
|
||||
import type { ThemeName } from "../kc.gen";
|
||||
import { createUseI18n } from "keycloakify/account";
|
||||
|
||||
const { useI18n, ofTypeI18n } = i18nBuilder
|
||||
.withThemeName<ThemeName>()
|
||||
.withExtraLanguages({})
|
||||
.withCustomTranslations({})
|
||||
.build();
|
||||
export const { useI18n, ofTypeI18n } = createUseI18n({});
|
||||
|
||||
type I18n = typeof ofTypeI18n;
|
||||
|
||||
export { useI18n, type I18n };
|
||||
export type I18n = typeof ofTypeI18n;
|
||||
|
@ -16,7 +16,6 @@ import { isInside } from "../../tools/isInside";
|
||||
import child_process from "child_process";
|
||||
import { rmSync } from "../../tools/fs.rmSync";
|
||||
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
|
||||
import { existsAsync } from "../../tools/fs.existsAsync";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_generatePom & {
|
||||
keycloakifyBuildDirPath: string;
|
||||
@ -136,49 +135,40 @@ export async function buildJar(params: {
|
||||
break route_legacy_pages;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
(["register.ftl", "login-update-profile.ftl"] as const)
|
||||
.map(pageId =>
|
||||
buildContext.themeNames.map(async themeName => {
|
||||
const ftlFilePath = pathJoin(
|
||||
tmpResourcesDirPath,
|
||||
"theme",
|
||||
themeName,
|
||||
"login",
|
||||
pageId
|
||||
);
|
||||
(["register.ftl", "login-update-profile.ftl"] as const).forEach(pageId =>
|
||||
buildContext.themeNames.map(themeName => {
|
||||
const ftlFilePath = pathJoin(
|
||||
tmpResourcesDirPath,
|
||||
"theme",
|
||||
themeName,
|
||||
"login",
|
||||
pageId
|
||||
);
|
||||
|
||||
// NOTE: https://github.com/keycloakify/keycloakify/issues/665
|
||||
if (!(await existsAsync(ftlFilePath))) {
|
||||
return;
|
||||
}
|
||||
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
|
||||
|
||||
const ftlFileContent = readFileSync(ftlFilePath).toString("utf8");
|
||||
const ftlFileBasename = (() => {
|
||||
switch (pageId) {
|
||||
case "register.ftl":
|
||||
return "register-user-profile.ftl";
|
||||
case "login-update-profile.ftl":
|
||||
return "update-user-profile.ftl";
|
||||
}
|
||||
assert<Equals<typeof pageId, never>>(false);
|
||||
})();
|
||||
|
||||
const ftlFileBasename = (() => {
|
||||
switch (pageId) {
|
||||
case "register.ftl":
|
||||
return "register-user-profile.ftl";
|
||||
case "login-update-profile.ftl":
|
||||
return "update-user-profile.ftl";
|
||||
}
|
||||
assert<Equals<typeof pageId, never>>(false);
|
||||
})();
|
||||
const modifiedFtlFileContent = ftlFileContent.replace(
|
||||
`"ftlTemplateFileName": "${pageId}"`,
|
||||
`"ftlTemplateFileName": "${ftlFileBasename}"`
|
||||
);
|
||||
|
||||
const modifiedFtlFileContent = ftlFileContent.replace(
|
||||
`"ftlTemplateFileName": "${pageId}"`,
|
||||
`"ftlTemplateFileName": "${ftlFileBasename}"`
|
||||
);
|
||||
assert(modifiedFtlFileContent !== ftlFileContent);
|
||||
|
||||
assert(modifiedFtlFileContent !== ftlFileContent);
|
||||
|
||||
await fs.writeFile(
|
||||
pathJoin(pathDirname(ftlFilePath), ftlFileBasename),
|
||||
Buffer.from(modifiedFtlFileContent, "utf8")
|
||||
);
|
||||
})
|
||||
)
|
||||
.flat()
|
||||
fs.writeFile(
|
||||
pathJoin(pathDirname(ftlFilePath), ftlFileBasename),
|
||||
Buffer.from(modifiedFtlFileContent, "utf8")
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@ -197,7 +187,7 @@ export async function buildJar(params: {
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
child_process.exec(
|
||||
`mvn clean install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`,
|
||||
`mvn install -Dmaven.repo.local="${pathJoin(keycloakifyBuildCacheDirPath, ".m2")}"`,
|
||||
{ cwd: keycloakifyBuildCacheDirPath },
|
||||
error => {
|
||||
if (error !== null) {
|
||||
|
@ -52,9 +52,9 @@ export function getKeycloakVersionRangeForJar(params: {
|
||||
case "0.6":
|
||||
switch (keycloakThemeAdditionalInfoExtensionVersion) {
|
||||
case null:
|
||||
return "26-and-above" as const;
|
||||
return undefined;
|
||||
case "1.1.5":
|
||||
return "25" as const;
|
||||
return "25-and-above" as const;
|
||||
}
|
||||
}
|
||||
assert<Equals<typeof keycloakAccountV1Version, never>>(false);
|
||||
@ -75,9 +75,9 @@ export function getKeycloakVersionRangeForJar(params: {
|
||||
}
|
||||
switch (keycloakThemeAdditionalInfoExtensionVersion) {
|
||||
case null:
|
||||
return "all-other-versions";
|
||||
return "21-and-below";
|
||||
case "1.1.5":
|
||||
return "22-to-25";
|
||||
return "22-and-above";
|
||||
}
|
||||
assert<Equals<typeof keycloakThemeAdditionalInfoExtensionVersion, never>>(
|
||||
false
|
||||
|
@ -87,17 +87,15 @@ attributes_to_attributesByName: {
|
||||
window.kcContext = kcContext;
|
||||
|
||||
<#if xKeycloakify.themeType == "login" >
|
||||
{
|
||||
const script = document.createElement("script");
|
||||
script.type = "importmap";
|
||||
script.textContent = JSON.stringify({
|
||||
imports: {
|
||||
"rfc4648": kcContext.url.resourcesCommonPath + "/node_modules/rfc4648/lib/rfc4648.js"
|
||||
}
|
||||
}, null, 2);
|
||||
const script = document.createElement("script");
|
||||
script.type = "importmap";
|
||||
script.textContent = JSON.stringify({
|
||||
imports: {
|
||||
"rfc4648": kcContext.url.resourcesCommonPath + "/node_modules/rfc4648/lib/rfc4648.js"
|
||||
}
|
||||
}, null, 2);
|
||||
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
document.head.appendChild(script);
|
||||
</#if>
|
||||
|
||||
function decodeHtmlEntities(htmlStr){
|
||||
@ -166,8 +164,11 @@ function decodeHtmlEntities(htmlStr){
|
||||
areSamePath(path, []) &&
|
||||
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(xKeycloakify.pageId)
|
||||
) || (
|
||||
["masterAdminClient", "delegateForUpdate", "defaultRole", "smtpConfig"]?seq_contains(key) &&
|
||||
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
|
||||
areSamePath(path, ["realm"])
|
||||
) || (
|
||||
"smtpConfig" == key &&
|
||||
are_same_path(path, ["realm"])
|
||||
) || (
|
||||
xKeycloakify.pageId == "error.ftl" &&
|
||||
areSamePath(path, ["realm"]) &&
|
||||
@ -235,9 +236,6 @@ function decodeHtmlEntities(htmlStr){
|
||||
"identityFederationEnabled",
|
||||
"userManagedAccessAllowed"
|
||||
]?seq_contains(key)
|
||||
) || (
|
||||
["flowContext", "session", "realm"]?seq_contains(key) &&
|
||||
areSamePath(path, ["social"])
|
||||
)
|
||||
>
|
||||
<#-- <#local outSeq += ["/*" + path?join(".") + "." + key + " excluded*/"]> -->
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { type ThemeType, FALLBACK_LANGUAGE_TAG } from "../../shared/constants";
|
||||
import { crawl } from "../../tools/crawl";
|
||||
import { join as pathJoin, dirname as pathDirname } from "path";
|
||||
import { join as pathJoin } from "path";
|
||||
import { symToStr } from "tsafe/symToStr";
|
||||
import * as recast from "recast";
|
||||
import * as babelParser from "@babel/parser";
|
||||
@ -10,27 +10,12 @@ import { escapeStringForPropertiesFile } from "../../tools/escapeStringForProper
|
||||
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
|
||||
import * as fs from "fs";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath";
|
||||
|
||||
export type BuildContextLike = {
|
||||
themeNames: string[];
|
||||
themeSrcDirPath: string;
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export function generateMessageProperties(params: {
|
||||
buildContext: BuildContextLike;
|
||||
themeSrcDirPath: string;
|
||||
themeType: ThemeType;
|
||||
}): {
|
||||
languageTags: string[];
|
||||
writeMessagePropertiesFiles: (params: {
|
||||
messageDirPath: string;
|
||||
themeName: string;
|
||||
}) => void;
|
||||
} {
|
||||
const { buildContext, themeType } = params;
|
||||
}): { languageTag: string; propertiesFileSource: string }[] {
|
||||
const { themeSrcDirPath, themeType } = params;
|
||||
|
||||
const baseMessagesDirPath = pathJoin(
|
||||
getThisCodebaseRootDirPath(),
|
||||
@ -40,49 +25,51 @@ export function generateMessageProperties(params: {
|
||||
"messages_defaultSet"
|
||||
);
|
||||
|
||||
const messages_defaultSet_by_languageTag_defaultSet: {
|
||||
[languageTag_defaultSet: string]: Record<string, string>;
|
||||
} = Object.fromEntries(
|
||||
fs
|
||||
.readdirSync(baseMessagesDirPath)
|
||||
.filter(basename => basename !== "index.ts" && basename !== "types.ts")
|
||||
.map(basename => ({
|
||||
languageTag: basename.replace(/\.ts$/, ""),
|
||||
filePath: pathJoin(baseMessagesDirPath, basename)
|
||||
}))
|
||||
.map(({ languageTag, filePath }) => {
|
||||
const lines = fs.readFileSync(filePath).toString("utf8").split(/\r?\n/);
|
||||
const baseMessageBundle: { [languageTag: string]: Record<string, string> } =
|
||||
Object.fromEntries(
|
||||
fs
|
||||
.readdirSync(baseMessagesDirPath)
|
||||
.filter(baseName => baseName !== "index.ts")
|
||||
.map(basename => ({
|
||||
languageTag: basename.replace(/\.ts$/, ""),
|
||||
filePath: pathJoin(baseMessagesDirPath, basename)
|
||||
}))
|
||||
.map(({ languageTag, filePath }) => {
|
||||
const lines = fs
|
||||
.readFileSync(filePath)
|
||||
.toString("utf8")
|
||||
.split(/\r?\n/);
|
||||
|
||||
let messagesJson = "{";
|
||||
let messagesJson = "{";
|
||||
|
||||
let isInDeclaration = false;
|
||||
let isInDeclaration = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!isInDeclaration) {
|
||||
if (line.startsWith("const messages")) {
|
||||
isInDeclaration = true;
|
||||
for (const line of lines) {
|
||||
if (!isInDeclaration) {
|
||||
if (line.startsWith("const messages")) {
|
||||
isInDeclaration = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
if (line.startsWith("}")) {
|
||||
messagesJson += "}";
|
||||
break;
|
||||
}
|
||||
|
||||
messagesJson += line;
|
||||
}
|
||||
|
||||
if (line.startsWith("}")) {
|
||||
messagesJson += "}";
|
||||
break;
|
||||
}
|
||||
const messages = JSON.parse(messagesJson) as Record<string, string>;
|
||||
|
||||
messagesJson += line;
|
||||
}
|
||||
|
||||
const messages = JSON.parse(messagesJson) as Record<string, string>;
|
||||
|
||||
return [languageTag, messages];
|
||||
})
|
||||
);
|
||||
return [languageTag, messages];
|
||||
})
|
||||
);
|
||||
|
||||
const { i18nTsFilePath } = (() => {
|
||||
let files = crawl({
|
||||
dirPath: pathJoin(buildContext.themeSrcDirPath, themeType),
|
||||
dirPath: pathJoin(themeSrcDirPath, themeType),
|
||||
returnedPathsType: "absolute"
|
||||
});
|
||||
|
||||
@ -101,7 +88,7 @@ export function generateMessageProperties(params: {
|
||||
files = files.sort((a, b) => a.length - b.length);
|
||||
|
||||
files = files.filter(file =>
|
||||
fs.readFileSync(file).toString("utf8").includes("i18nBuilder")
|
||||
fs.readFileSync(file).toString("utf8").includes("createUseI18n(")
|
||||
);
|
||||
|
||||
const i18nTsFilePath: string | undefined = files[0];
|
||||
@ -109,334 +96,97 @@ export function generateMessageProperties(params: {
|
||||
return { i18nTsFilePath };
|
||||
})();
|
||||
|
||||
const i18nTsRoot = (() => {
|
||||
if (i18nTsFilePath === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const root = recastParseTs(i18nTsFilePath);
|
||||
return root;
|
||||
})();
|
||||
|
||||
const messages_defaultSet_by_languageTag_notInDefaultSet:
|
||||
| { [languageTag_notInDefaultSet: string]: Record<string, string> }
|
||||
| undefined = (() => {
|
||||
if (i18nTsRoot === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let extraLanguageEntryByLanguageTag: Record<
|
||||
string,
|
||||
{ label: string; path: string }
|
||||
> = {};
|
||||
|
||||
recast.visit(i18nTsRoot, {
|
||||
visitCallExpression: function (path) {
|
||||
const node = path.node;
|
||||
|
||||
// Check if the callee is a MemberExpression with property 'withExtraLanguages'
|
||||
if (
|
||||
node.callee.type === "MemberExpression" &&
|
||||
node.callee.property.type === "Identifier" &&
|
||||
node.callee.property.name === "withExtraLanguages"
|
||||
) {
|
||||
const arg = node.arguments[0];
|
||||
if (arg && arg.type === "ObjectExpression") {
|
||||
// Iterate over the properties of the object
|
||||
arg.properties.forEach(prop => {
|
||||
if (
|
||||
prop.type === "ObjectProperty" &&
|
||||
prop.key.type === "Identifier"
|
||||
) {
|
||||
const lang = prop.key.name;
|
||||
const value = prop.value;
|
||||
|
||||
if (value.type === "ObjectExpression") {
|
||||
let label: string | undefined = undefined;
|
||||
let pathStr: string | undefined = undefined;
|
||||
|
||||
// Iterate over the properties of the language object
|
||||
value.properties.forEach(p => {
|
||||
if (
|
||||
p.type === "ObjectProperty" &&
|
||||
p.key.type === "Identifier"
|
||||
) {
|
||||
if (
|
||||
p.key.name === "label" &&
|
||||
p.value.type === "StringLiteral"
|
||||
) {
|
||||
label = p.value.value;
|
||||
}
|
||||
if (
|
||||
p.key.name === "getMessages" &&
|
||||
(p.value.type ===
|
||||
"ArrowFunctionExpression" ||
|
||||
p.value.type === "FunctionExpression")
|
||||
) {
|
||||
// Extract the import path from the function body
|
||||
const body = p.value.body;
|
||||
if (
|
||||
body.type === "CallExpression" &&
|
||||
body.callee.type === "Import"
|
||||
) {
|
||||
const importArg = body.arguments[0];
|
||||
if (
|
||||
importArg.type === "StringLiteral"
|
||||
) {
|
||||
pathStr = importArg.value;
|
||||
}
|
||||
} else if (
|
||||
body.type === "BlockStatement"
|
||||
) {
|
||||
// If the function body is a block (e.g., function with braces {})
|
||||
// Look for return statement
|
||||
body.body.forEach(statement => {
|
||||
if (
|
||||
statement.type ===
|
||||
"ReturnStatement" &&
|
||||
statement.argument &&
|
||||
statement.argument.type ===
|
||||
"CallExpression" &&
|
||||
statement.argument.callee
|
||||
.type === "Import"
|
||||
) {
|
||||
const importArg =
|
||||
statement.argument
|
||||
.arguments[0];
|
||||
if (
|
||||
importArg.type ===
|
||||
"StringLiteral"
|
||||
) {
|
||||
pathStr = importArg.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (label && pathStr) {
|
||||
extraLanguageEntryByLanguageTag[lang] = {
|
||||
label,
|
||||
path: pathStr
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return false; // Stop traversing this path
|
||||
}
|
||||
|
||||
this.traverse(path); // Continue traversing other paths
|
||||
const messageBundle: { [languageTag: string]: Record<string, string> } | undefined =
|
||||
(() => {
|
||||
if (i18nTsFilePath === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const messages_defaultSet_by_languageTag_notInDefaultSet = Object.fromEntries(
|
||||
Object.entries(extraLanguageEntryByLanguageTag).map(
|
||||
([languageTag, { path: relativePathWithoutExt }]) => [
|
||||
languageTag,
|
||||
(() => {
|
||||
const filePath = getAbsoluteAndInOsFormatPath({
|
||||
pathIsh: relativePathWithoutExt.endsWith(".ts")
|
||||
? relativePathWithoutExt
|
||||
: `${relativePathWithoutExt}.ts`,
|
||||
cwd: pathDirname(i18nTsFilePath)
|
||||
});
|
||||
|
||||
const root = recastParseTs(filePath);
|
||||
|
||||
let declarationCode: string | undefined = "";
|
||||
|
||||
recast.visit(root, {
|
||||
visitVariableDeclarator: function (path) {
|
||||
const node = path.node;
|
||||
|
||||
// Check if the variable name is 'messages'
|
||||
if (
|
||||
node.id.type === "Identifier" &&
|
||||
node.id.name === "messages"
|
||||
) {
|
||||
// Ensure there is an initializer
|
||||
if (node.init) {
|
||||
// Generate code from the initializer, preserving comments
|
||||
declarationCode = recast
|
||||
.print(node.init)
|
||||
.code.replace(/}.*$/, "}");
|
||||
}
|
||||
return false; // Stop traversing this path
|
||||
}
|
||||
|
||||
this.traverse(path); // Continue traversing other paths
|
||||
}
|
||||
});
|
||||
|
||||
assert(
|
||||
declarationCode !== undefined,
|
||||
`${filePath} does not contain a 'messages' variable declaration`
|
||||
);
|
||||
|
||||
let messages: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
eval(`${symToStr({ messages })} = ${declarationCode};`);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`The declaration of 'message' in ${filePath} cannot be statically evaluated: ${declarationCode}`
|
||||
);
|
||||
}
|
||||
|
||||
return messages;
|
||||
})()
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
return messages_defaultSet_by_languageTag_notInDefaultSet;
|
||||
})();
|
||||
|
||||
const messages_defaultSet_by_languageTag = {
|
||||
...messages_defaultSet_by_languageTag_defaultSet,
|
||||
...messages_defaultSet_by_languageTag_notInDefaultSet
|
||||
};
|
||||
|
||||
const messages_themeDefined_by_languageTag:
|
||||
| {
|
||||
[languageTag: string]:
|
||||
| Record<string, string | Record<string, string>>
|
||||
| undefined;
|
||||
}
|
||||
| undefined = (() => {
|
||||
if (i18nTsRoot === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let firstArgumentCode: string | undefined = undefined;
|
||||
|
||||
recast.visit(i18nTsRoot, {
|
||||
visitCallExpression: function (path) {
|
||||
const node = path.node;
|
||||
|
||||
if (
|
||||
node.callee.type === "MemberExpression" &&
|
||||
node.callee.property.type === "Identifier" &&
|
||||
node.callee.property.name === "withCustomTranslations"
|
||||
) {
|
||||
firstArgumentCode = babelGenerate(node.arguments[0] as any).code;
|
||||
return false;
|
||||
const root = recast.parse(fs.readFileSync(i18nTsFilePath).toString("utf8"), {
|
||||
parser: {
|
||||
parse: (code: string) =>
|
||||
babelParser.parse(code, {
|
||||
sourceType: "module",
|
||||
plugins: ["typescript"]
|
||||
}),
|
||||
generator: babelGenerate,
|
||||
types: babelTypes
|
||||
}
|
||||
});
|
||||
|
||||
this.traverse(path);
|
||||
}
|
||||
});
|
||||
let messageBundleDeclarationTsCode: string | undefined = undefined;
|
||||
|
||||
if (firstArgumentCode === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let messages_themeDefined_by_languageTag: {
|
||||
[languageTag: string]: Record<string, string | Record<string, string>>;
|
||||
} = {};
|
||||
|
||||
try {
|
||||
eval(
|
||||
`${symToStr({ messages_themeDefined_by_languageTag })} = ${firstArgumentCode}`
|
||||
);
|
||||
} catch {
|
||||
console.warn(
|
||||
[
|
||||
"WARNING: The argument of withCustomTranslations can't be statically evaluated!",
|
||||
"This needs to be fixed refer to the documentation: https://docs.keycloakify.dev/i18n",
|
||||
firstArgumentCode
|
||||
].join(" ")
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return messages_themeDefined_by_languageTag;
|
||||
})();
|
||||
|
||||
const languageTags = Object.keys(messages_defaultSet_by_languageTag);
|
||||
|
||||
return {
|
||||
languageTags,
|
||||
writeMessagePropertiesFiles: ({ messageDirPath, themeName }) => {
|
||||
for (const languageTag of languageTags) {
|
||||
const messages = {
|
||||
...messages_defaultSet_by_languageTag[languageTag]
|
||||
};
|
||||
|
||||
add_theme_defined_messages: {
|
||||
if (messages_themeDefined_by_languageTag === undefined) {
|
||||
break add_theme_defined_messages;
|
||||
recast.visit(root, {
|
||||
visitCallExpression: function (path) {
|
||||
if (
|
||||
path.node.callee.type === "Identifier" &&
|
||||
path.node.callee.name === "createUseI18n"
|
||||
) {
|
||||
messageBundleDeclarationTsCode = babelGenerate(
|
||||
path.node.arguments[0] as any
|
||||
).code;
|
||||
return false;
|
||||
}
|
||||
|
||||
let messages_themeDefined =
|
||||
messages_themeDefined_by_languageTag[languageTag];
|
||||
|
||||
if (messages_themeDefined === undefined) {
|
||||
messages_themeDefined =
|
||||
messages_themeDefined_by_languageTag[FALLBACK_LANGUAGE_TAG];
|
||||
}
|
||||
if (messages_themeDefined === undefined) {
|
||||
messages_themeDefined =
|
||||
messages_themeDefined_by_languageTag[
|
||||
Object.keys(messages_themeDefined_by_languageTag)[0]
|
||||
];
|
||||
}
|
||||
if (messages_themeDefined === undefined) {
|
||||
break add_theme_defined_messages;
|
||||
}
|
||||
|
||||
for (const [key, messageOrMessageByThemeName] of Object.entries(
|
||||
messages_themeDefined
|
||||
)) {
|
||||
const message = (() => {
|
||||
if (typeof messageOrMessageByThemeName === "string") {
|
||||
return messageOrMessageByThemeName;
|
||||
}
|
||||
|
||||
const message = messageOrMessageByThemeName[themeName];
|
||||
|
||||
assert(message !== undefined);
|
||||
|
||||
return message;
|
||||
})();
|
||||
|
||||
messages[key] = message;
|
||||
}
|
||||
this.traverse(path);
|
||||
}
|
||||
});
|
||||
|
||||
const propertiesFileSource = [
|
||||
"",
|
||||
...Object.entries(messages).map(
|
||||
([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`
|
||||
),
|
||||
""
|
||||
].join("\n");
|
||||
assert(messageBundleDeclarationTsCode !== undefined);
|
||||
|
||||
fs.mkdirSync(messageDirPath, { recursive: true });
|
||||
let messageBundle: {
|
||||
[languageTag: string]: Record<string, string>;
|
||||
} = {};
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(messageDirPath, `messages_${languageTag}.properties`),
|
||||
Buffer.from(propertiesFileSource, "utf8")
|
||||
try {
|
||||
eval(
|
||||
`${symToStr({ messageBundle })} = ${messageBundleDeclarationTsCode}`
|
||||
);
|
||||
} catch {
|
||||
console.warn(
|
||||
[
|
||||
"WARNING: Make sure the messageBundle your provided as argument of createUseI18n can be statically evaluated.",
|
||||
"This is important because we need to put your i18n messages in messages_*.properties files",
|
||||
"or they won't be available server side.",
|
||||
"\n",
|
||||
"The following code could not be evaluated:",
|
||||
"\n",
|
||||
messageBundleDeclarationTsCode
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function recastParseTs(filePath: string): recast.types.ASTNode {
|
||||
return recast.parse(fs.readFileSync(filePath).toString("utf8"), {
|
||||
parser: {
|
||||
parse: (code: string) =>
|
||||
babelParser.parse(code, {
|
||||
sourceType: "module",
|
||||
plugins: ["typescript"]
|
||||
}),
|
||||
generator: babelGenerate,
|
||||
types: babelTypes
|
||||
}
|
||||
});
|
||||
return messageBundle;
|
||||
})();
|
||||
|
||||
const mergedMessageBundle: { [languageTag: string]: Record<string, string> } =
|
||||
Object.fromEntries(
|
||||
Object.entries(baseMessageBundle).map(([languageTag, messages]) => [
|
||||
languageTag,
|
||||
{
|
||||
...messages,
|
||||
...(messageBundle === undefined
|
||||
? {}
|
||||
: messageBundle[languageTag] ??
|
||||
messageBundle[FALLBACK_LANGUAGE_TAG] ??
|
||||
messageBundle[Object.keys(messageBundle)[0]] ??
|
||||
{})
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
const messageProperties: { languageTag: string; propertiesFileSource: string }[] =
|
||||
Object.entries(mergedMessageBundle).map(([languageTag, messages]) => ({
|
||||
languageTag,
|
||||
propertiesFileSource: [
|
||||
"",
|
||||
...(themeType !== "account" ? ["parent=base"] : []),
|
||||
...Object.entries(messages).map(
|
||||
([key, value]) => `${key}=${escapeStringForPropertiesFile(value)}`
|
||||
),
|
||||
""
|
||||
].join("\n")
|
||||
}));
|
||||
|
||||
return messageProperties;
|
||||
}
|
||||
|
@ -1,56 +1,16 @@
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
import { assert } from "tsafe/assert";
|
||||
import {
|
||||
generateResourcesForMainTheme,
|
||||
type BuildContextLike as BuildContextLike_generateResourcesForMainTheme
|
||||
} from "./generateResourcesForMainTheme";
|
||||
import { generateResourcesForThemeVariant } from "./generateResourcesForThemeVariant";
|
||||
import fs from "fs";
|
||||
import { rmSync } from "../../tools/fs.rmSync";
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import {
|
||||
join as pathJoin,
|
||||
relative as pathRelative,
|
||||
dirname as pathDirname,
|
||||
extname as pathExtname,
|
||||
sep as pathSep
|
||||
} from "path";
|
||||
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
|
||||
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
|
||||
import {
|
||||
generateFtlFilesCodeFactory,
|
||||
type BuildContextLike as BuildContextLike_kcContextExclusionsFtlCode
|
||||
} from "../generateFtl";
|
||||
import {
|
||||
type ThemeType,
|
||||
LOGIN_THEME_PAGE_IDS,
|
||||
ACCOUNT_THEME_PAGE_IDS,
|
||||
WELL_KNOWN_DIRECTORY_BASE_NAME
|
||||
} from "../../shared/constants";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import { readFieldNameUsage } from "./readFieldNameUsage";
|
||||
import { readExtraPagesNames } from "./readExtraPageNames";
|
||||
import {
|
||||
generateMessageProperties,
|
||||
type BuildContextLike as BuildContextLike_generateMessageProperties
|
||||
} from "./generateMessageProperties";
|
||||
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
|
||||
import {
|
||||
writeMetaInfKeycloakThemes,
|
||||
type MetaInfKeycloakTheme
|
||||
} from "../../shared/metaInfKeycloakThemes";
|
||||
import { objectEntries } from "tsafe/objectEntries";
|
||||
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
|
||||
import * as child_process from "child_process";
|
||||
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
|
||||
import propertiesParser from "properties-parser";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode &
|
||||
BuildContextLike_generateMessageProperties & {
|
||||
themeNames: string[];
|
||||
extraThemeProperties: string[] | undefined;
|
||||
projectDirPath: string;
|
||||
projectBuildDirPath: string;
|
||||
environmentVariables: { name: string; default: string }[];
|
||||
implementedThemeTypes: BuildContext["implementedThemeTypes"];
|
||||
themeSrcDirPath: string;
|
||||
bundler: "vite" | "webpack";
|
||||
packageJsonFilePath: string;
|
||||
};
|
||||
export type BuildContextLike = BuildContextLike_generateResourcesForMainTheme & {
|
||||
themeNames: string[];
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
@ -60,443 +20,23 @@ export async function generateResources(params: {
|
||||
}): Promise<void> {
|
||||
const { resourcesDirPath, buildContext } = params;
|
||||
|
||||
const [themeName] = buildContext.themeNames;
|
||||
const [themeName, ...themeVariantNames] = buildContext.themeNames;
|
||||
|
||||
if (fs.existsSync(resourcesDirPath)) {
|
||||
rmSync(resourcesDirPath, { recursive: true });
|
||||
}
|
||||
|
||||
const getThemeTypeDirPath = (params: {
|
||||
themeType: ThemeType | "email";
|
||||
themeName: string;
|
||||
}) => {
|
||||
const { themeType, themeName } = params;
|
||||
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
|
||||
};
|
||||
await generateResourcesForMainTheme({
|
||||
resourcesDirPath,
|
||||
themeName,
|
||||
buildContext
|
||||
});
|
||||
|
||||
const writeMessagePropertiesFilesByThemeType: Partial<
|
||||
Record<ThemeType, (params: { messageDirPath: string; themeName: string }) => void>
|
||||
> = {};
|
||||
|
||||
for (const themeType of ["login", "account"] as const) {
|
||||
if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isForAccountSpa =
|
||||
themeType === "account" &&
|
||||
(assert(buildContext.implementedThemeTypes.account.isImplemented),
|
||||
buildContext.implementedThemeTypes.account.type === "Single-Page");
|
||||
|
||||
const themeTypeDirPath = getThemeTypeDirPath({ themeName, themeType });
|
||||
|
||||
apply_replacers_and_move_to_theme_resources: {
|
||||
const destDirPath = pathJoin(
|
||||
themeTypeDirPath,
|
||||
"resources",
|
||||
WELL_KNOWN_DIRECTORY_BASE_NAME.DIST
|
||||
);
|
||||
|
||||
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
|
||||
rmSync(destDirPath, { recursive: true, force: true });
|
||||
|
||||
if (
|
||||
themeType === "account" &&
|
||||
buildContext.implementedThemeTypes.login.isImplemented
|
||||
) {
|
||||
// NOTE: We prevent doing it twice, it has been done for the login theme.
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: pathJoin(
|
||||
getThemeTypeDirPath({
|
||||
themeName,
|
||||
themeType: "login"
|
||||
}),
|
||||
"resources",
|
||||
WELL_KNOWN_DIRECTORY_BASE_NAME.DIST
|
||||
),
|
||||
destDirPath
|
||||
});
|
||||
|
||||
break apply_replacers_and_move_to_theme_resources;
|
||||
}
|
||||
|
||||
{
|
||||
const dirPath = pathJoin(
|
||||
buildContext.projectBuildDirPath,
|
||||
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
|
||||
);
|
||||
|
||||
if (fs.existsSync(dirPath)) {
|
||||
assert(buildContext.bundler === "webpack");
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
`Keycloakify build error: The ${WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES} directory shouldn't exist in your build directory.`,
|
||||
`(${pathRelative(process.cwd(), dirPath)}).\n`,
|
||||
`Theses assets are only required for local development with Storybook.",
|
||||
"Please remove this directory as an additional step of your command.\n`,
|
||||
`For example: \`"build": "... && rimraf ${pathRelative(buildContext.projectDirPath, dirPath)}"\``
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: buildContext.projectBuildDirPath,
|
||||
destDirPath,
|
||||
transformSourceCode: ({ filePath, fileRelativePath, sourceCode }) => {
|
||||
if (filePath.endsWith(".css")) {
|
||||
const { fixedCssCode } = replaceImportsInCssCode({
|
||||
cssCode: sourceCode.toString("utf8"),
|
||||
cssFileRelativeDirPath: pathDirname(fileRelativePath),
|
||||
buildContext
|
||||
});
|
||||
|
||||
return {
|
||||
modifiedSourceCode: Buffer.from(fixedCssCode, "utf8")
|
||||
};
|
||||
}
|
||||
|
||||
if (filePath.endsWith(".js")) {
|
||||
const { fixedJsCode } = replaceImportsInJsCode({
|
||||
jsCode: sourceCode.toString("utf8"),
|
||||
buildContext
|
||||
});
|
||||
|
||||
return {
|
||||
modifiedSourceCode: Buffer.from(fixedJsCode, "utf8")
|
||||
};
|
||||
}
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
|
||||
themeName,
|
||||
indexHtmlCode: fs
|
||||
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
|
||||
.toString("utf8"),
|
||||
buildContext,
|
||||
keycloakifyVersion: readThisNpmPackageVersion(),
|
||||
themeType,
|
||||
fieldNames: readFieldNameUsage({
|
||||
themeSrcDirPath: buildContext.themeSrcDirPath,
|
||||
themeType
|
||||
})
|
||||
});
|
||||
|
||||
[
|
||||
...(() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return LOGIN_THEME_PAGE_IDS;
|
||||
case "account":
|
||||
return isForAccountSpa ? ["index.ftl"] : ACCOUNT_THEME_PAGE_IDS;
|
||||
}
|
||||
})(),
|
||||
...(isForAccountSpa
|
||||
? []
|
||||
: readExtraPagesNames({
|
||||
themeType,
|
||||
themeSrcDirPath: buildContext.themeSrcDirPath
|
||||
}))
|
||||
].forEach(pageId => {
|
||||
const { ftlCode } = generateFtlFilesCode({ pageId });
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(themeTypeDirPath, pageId),
|
||||
Buffer.from(ftlCode, "utf8")
|
||||
);
|
||||
});
|
||||
|
||||
let languageTags: string[] | undefined = undefined;
|
||||
|
||||
i18n_messages_generation: {
|
||||
if (isForAccountSpa) {
|
||||
break i18n_messages_generation;
|
||||
}
|
||||
|
||||
const wrap = generateMessageProperties({
|
||||
buildContext,
|
||||
themeType
|
||||
});
|
||||
|
||||
languageTags = wrap.languageTags;
|
||||
const { writeMessagePropertiesFiles } = wrap;
|
||||
|
||||
writeMessagePropertiesFilesByThemeType[themeType] =
|
||||
writeMessagePropertiesFiles;
|
||||
}
|
||||
|
||||
bring_in_account_v3_i18n_messages: {
|
||||
if (!buildContext.implementedThemeTypes.account.isImplemented) {
|
||||
break bring_in_account_v3_i18n_messages;
|
||||
}
|
||||
if (buildContext.implementedThemeTypes.account.type !== "Single-Page") {
|
||||
break bring_in_account_v3_i18n_messages;
|
||||
}
|
||||
|
||||
const accountUiDirPath = child_process
|
||||
.execSync("npm list @keycloakify/keycloak-account-ui --parseable", {
|
||||
cwd: pathDirname(buildContext.packageJsonFilePath)
|
||||
})
|
||||
.toString("utf8")
|
||||
.trim();
|
||||
|
||||
const messageDirPath_defaults = pathJoin(accountUiDirPath, "messages");
|
||||
|
||||
if (!fs.existsSync(messageDirPath_defaults)) {
|
||||
throw new Error(
|
||||
`Please update @keycloakify/keycloak-account-ui to 25.0.4-rc.5 or later.`
|
||||
);
|
||||
}
|
||||
|
||||
const messagesDirPath_dest = pathJoin(
|
||||
getThemeTypeDirPath({ themeName, themeType: "account" }),
|
||||
"messages"
|
||||
);
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: messageDirPath_defaults,
|
||||
destDirPath: messagesDirPath_dest
|
||||
});
|
||||
|
||||
apply_theme_changes: {
|
||||
const messagesDirPath_theme = pathJoin(
|
||||
buildContext.themeSrcDirPath,
|
||||
"account",
|
||||
"messages"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(messagesDirPath_theme)) {
|
||||
break apply_theme_changes;
|
||||
}
|
||||
|
||||
fs.readdirSync(messagesDirPath_theme).forEach(basename => {
|
||||
const filePath_src = pathJoin(messagesDirPath_theme, basename);
|
||||
const filePath_dest = pathJoin(messagesDirPath_dest, basename);
|
||||
|
||||
if (!fs.existsSync(filePath_dest)) {
|
||||
fs.cpSync(filePath_src, filePath_dest);
|
||||
}
|
||||
|
||||
const messages_src = propertiesParser.parse(
|
||||
fs.readFileSync(filePath_src).toString("utf8")
|
||||
);
|
||||
const messages_dest = propertiesParser.parse(
|
||||
fs.readFileSync(filePath_dest).toString("utf8")
|
||||
);
|
||||
|
||||
const messages = {
|
||||
...messages_dest,
|
||||
...messages_src
|
||||
};
|
||||
|
||||
const editor = propertiesParser.createEditor();
|
||||
|
||||
Object.entries(messages).forEach(([key, value]) => {
|
||||
editor.set(key, value);
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
filePath_dest,
|
||||
Buffer.from(editor.toString(), "utf8")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
languageTags = fs
|
||||
.readdirSync(messagesDirPath_dest)
|
||||
.map(basename =>
|
||||
basename.replace(/^messages_/, "").replace(/\.properties$/, "")
|
||||
);
|
||||
}
|
||||
|
||||
keycloak_static_resources: {
|
||||
if (isForAccountSpa) {
|
||||
break keycloak_static_resources;
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: pathJoin(
|
||||
getThisCodebaseRootDirPath(),
|
||||
"res",
|
||||
"public",
|
||||
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES,
|
||||
themeType
|
||||
),
|
||||
destDirPath: pathJoin(themeTypeDirPath, "resources")
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(themeTypeDirPath, "theme.properties"),
|
||||
Buffer.from(
|
||||
[
|
||||
`parent=${(() => {
|
||||
switch (themeType) {
|
||||
case "account":
|
||||
return isForAccountSpa ? "base" : "account-v1";
|
||||
case "login":
|
||||
return "keycloak";
|
||||
}
|
||||
assert<Equals<typeof themeType, never>>(false);
|
||||
})()}`,
|
||||
...(isForAccountSpa ? ["deprecatedMode=false"] : []),
|
||||
...(buildContext.extraThemeProperties ?? []),
|
||||
...buildContext.environmentVariables.map(
|
||||
({ name, default: defaultValue }) =>
|
||||
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
|
||||
),
|
||||
...(languageTags === undefined
|
||||
? []
|
||||
: [`locales=${languageTags.join(",")}`])
|
||||
].join("\n\n"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
email: {
|
||||
if (!buildContext.implementedThemeTypes.email.isImplemented) {
|
||||
break email;
|
||||
}
|
||||
|
||||
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: emailThemeSrcDirPath,
|
||||
destDirPath: getThemeTypeDirPath({ themeName, themeType: "email" })
|
||||
});
|
||||
}
|
||||
|
||||
bring_in_account_v1: {
|
||||
if (!buildContext.implementedThemeTypes.account.isImplemented) {
|
||||
break bring_in_account_v1;
|
||||
}
|
||||
|
||||
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
|
||||
break bring_in_account_v1;
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
|
||||
destDirPath: getThemeTypeDirPath({
|
||||
themeName: "account-v1",
|
||||
themeType: "account"
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
|
||||
|
||||
for (const themeName of buildContext.themeNames) {
|
||||
metaInfKeycloakThemes.themes.push({
|
||||
name: themeName,
|
||||
types: objectEntries(buildContext.implementedThemeTypes)
|
||||
.filter(([, { isImplemented }]) => isImplemented)
|
||||
.map(([themeType]) => themeType)
|
||||
});
|
||||
}
|
||||
|
||||
if (buildContext.implementedThemeTypes.account.isImplemented) {
|
||||
metaInfKeycloakThemes.themes.push({
|
||||
name: "account-v1",
|
||||
types: ["account"]
|
||||
});
|
||||
}
|
||||
|
||||
writeMetaInfKeycloakThemes({
|
||||
for (const themeVariantName of themeVariantNames) {
|
||||
generateResourcesForThemeVariant({
|
||||
resourcesDirPath,
|
||||
getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes
|
||||
themeName,
|
||||
themeVariantName
|
||||
});
|
||||
}
|
||||
|
||||
for (const themeVariantName of buildContext.themeNames) {
|
||||
if (themeVariantName === themeName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: pathJoin(resourcesDirPath, "theme", themeName),
|
||||
destDirPath: pathJoin(resourcesDirPath, "theme", themeVariantName),
|
||||
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
|
||||
if (
|
||||
pathExtname(fileRelativePath) === ".ftl" &&
|
||||
fileRelativePath.split(pathSep).length === 2
|
||||
) {
|
||||
const modifiedSourceCode = Buffer.from(
|
||||
Buffer.from(sourceCode)
|
||||
.toString("utf-8")
|
||||
.replace(
|
||||
`"themeName": "${themeName}"`,
|
||||
`"themeName": "${themeVariantName}"`
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
|
||||
|
||||
return { modifiedSourceCode };
|
||||
}
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const themeName of buildContext.themeNames) {
|
||||
for (const [themeType, writeMessagePropertiesFiles] of objectEntries(
|
||||
writeMessagePropertiesFilesByThemeType
|
||||
)) {
|
||||
// NOTE: This is just a quirk of the type system: We can't really differentiate in a record
|
||||
// between the case where the key isn't present and the case where the value is `undefined`.
|
||||
if (writeMessagePropertiesFiles === undefined) {
|
||||
return;
|
||||
}
|
||||
writeMessagePropertiesFiles({
|
||||
messageDirPath: pathJoin(
|
||||
getThemeTypeDirPath({ themeName, themeType }),
|
||||
"messages"
|
||||
),
|
||||
themeName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
modify_email_theme_per_variant: {
|
||||
if (!buildContext.implementedThemeTypes.email.isImplemented) {
|
||||
break modify_email_theme_per_variant;
|
||||
}
|
||||
|
||||
for (const themeName of buildContext.themeNames) {
|
||||
const emailThemeDirPath = getThemeTypeDirPath({
|
||||
themeName,
|
||||
themeType: "email"
|
||||
});
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: emailThemeDirPath,
|
||||
destDirPath: emailThemeDirPath,
|
||||
transformSourceCode: ({ filePath, sourceCode }) => {
|
||||
if (!filePath.endsWith(".ftl")) {
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
|
||||
return {
|
||||
modifiedSourceCode: Buffer.from(
|
||||
sourceCode
|
||||
.toString("utf8")
|
||||
.replace(/xKeycloakify\.themeName/g, `"${themeName}"`),
|
||||
"utf8"
|
||||
)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,341 @@
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import * as fs from "fs";
|
||||
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
|
||||
import { replaceImportsInJsCode } from "../replacers/replaceImportsInJsCode";
|
||||
import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode";
|
||||
import {
|
||||
generateFtlFilesCodeFactory,
|
||||
type BuildContextLike as BuildContextLike_kcContextExclusionsFtlCode
|
||||
} from "../generateFtl";
|
||||
import {
|
||||
type ThemeType,
|
||||
LOGIN_THEME_PAGE_IDS,
|
||||
ACCOUNT_THEME_PAGE_IDS,
|
||||
WELL_KNOWN_DIRECTORY_BASE_NAME
|
||||
} from "../../shared/constants";
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import { readFieldNameUsage } from "./readFieldNameUsage";
|
||||
import { readExtraPagesNames } from "./readExtraPageNames";
|
||||
import { generateMessageProperties } from "./generateMessageProperties";
|
||||
import { rmSync } from "../../tools/fs.rmSync";
|
||||
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
|
||||
import {
|
||||
writeMetaInfKeycloakThemes,
|
||||
type MetaInfKeycloakTheme
|
||||
} from "../../shared/metaInfKeycloakThemes";
|
||||
import { objectEntries } from "tsafe/objectEntries";
|
||||
import { escapeStringForPropertiesFile } from "../../tools/escapeStringForPropertiesFile";
|
||||
import * as child_process from "child_process";
|
||||
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
|
||||
|
||||
export type BuildContextLike = BuildContextLike_kcContextExclusionsFtlCode & {
|
||||
extraThemeProperties: string[] | undefined;
|
||||
projectDirPath: string;
|
||||
projectBuildDirPath: string;
|
||||
environmentVariables: { name: string; default: string }[];
|
||||
implementedThemeTypes: BuildContext["implementedThemeTypes"];
|
||||
themeSrcDirPath: string;
|
||||
bundler: "vite" | "webpack";
|
||||
packageJsonFilePath: string;
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export async function generateResourcesForMainTheme(params: {
|
||||
themeName: string;
|
||||
resourcesDirPath: string;
|
||||
buildContext: BuildContextLike;
|
||||
}): Promise<void> {
|
||||
const { themeName, resourcesDirPath, buildContext } = params;
|
||||
|
||||
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => {
|
||||
const { themeType } = params;
|
||||
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
|
||||
};
|
||||
|
||||
for (const themeType of ["login", "account"] as const) {
|
||||
if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isForAccountSpa =
|
||||
themeType === "account" &&
|
||||
(assert(buildContext.implementedThemeTypes.account.isImplemented),
|
||||
buildContext.implementedThemeTypes.account.type === "Single-Page");
|
||||
|
||||
const themeTypeDirPath = getThemeTypeDirPath({ themeType });
|
||||
|
||||
apply_replacers_and_move_to_theme_resources: {
|
||||
const destDirPath = pathJoin(
|
||||
themeTypeDirPath,
|
||||
"resources",
|
||||
WELL_KNOWN_DIRECTORY_BASE_NAME.DIST
|
||||
);
|
||||
|
||||
// NOTE: Prevent accumulation of files in the assets dir, as names are hashed they pile up.
|
||||
rmSync(destDirPath, { recursive: true, force: true });
|
||||
|
||||
if (
|
||||
themeType === "account" &&
|
||||
buildContext.implementedThemeTypes.login.isImplemented
|
||||
) {
|
||||
// NOTE: We prevent doing it twice, it has been done for the login theme.
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: pathJoin(
|
||||
getThemeTypeDirPath({
|
||||
themeType: "login"
|
||||
}),
|
||||
"resources",
|
||||
WELL_KNOWN_DIRECTORY_BASE_NAME.DIST
|
||||
),
|
||||
destDirPath
|
||||
});
|
||||
|
||||
break apply_replacers_and_move_to_theme_resources;
|
||||
}
|
||||
|
||||
{
|
||||
const dirPath = pathJoin(
|
||||
buildContext.projectBuildDirPath,
|
||||
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
|
||||
);
|
||||
|
||||
if (fs.existsSync(dirPath)) {
|
||||
assert(buildContext.bundler === "webpack");
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
`Keycloakify build error: The ${WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES} directory shouldn't exist in your build directory.`,
|
||||
`(${pathRelative(process.cwd(), dirPath)}).\n`,
|
||||
`Theses assets are only required for local development with Storybook.",
|
||||
"Please remove this directory as an additional step of your command.\n`,
|
||||
`For example: \`"build": "... && rimraf ${pathRelative(buildContext.projectDirPath, dirPath)}"\``
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: buildContext.projectBuildDirPath,
|
||||
destDirPath,
|
||||
transformSourceCode: ({ filePath, fileRelativePath, sourceCode }) => {
|
||||
if (filePath.endsWith(".css")) {
|
||||
const { fixedCssCode } = replaceImportsInCssCode({
|
||||
cssCode: sourceCode.toString("utf8"),
|
||||
cssFileRelativeDirPath: pathDirname(fileRelativePath),
|
||||
buildContext
|
||||
});
|
||||
|
||||
return {
|
||||
modifiedSourceCode: Buffer.from(fixedCssCode, "utf8")
|
||||
};
|
||||
}
|
||||
|
||||
if (filePath.endsWith(".js")) {
|
||||
const { fixedJsCode } = replaceImportsInJsCode({
|
||||
jsCode: sourceCode.toString("utf8"),
|
||||
buildContext
|
||||
});
|
||||
|
||||
return {
|
||||
modifiedSourceCode: Buffer.from(fixedJsCode, "utf8")
|
||||
};
|
||||
}
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { generateFtlFilesCode } = generateFtlFilesCodeFactory({
|
||||
themeName,
|
||||
indexHtmlCode: fs
|
||||
.readFileSync(pathJoin(buildContext.projectBuildDirPath, "index.html"))
|
||||
.toString("utf8"),
|
||||
buildContext,
|
||||
keycloakifyVersion: readThisNpmPackageVersion(),
|
||||
themeType,
|
||||
fieldNames: readFieldNameUsage({
|
||||
themeSrcDirPath: buildContext.themeSrcDirPath,
|
||||
themeType
|
||||
})
|
||||
});
|
||||
|
||||
[
|
||||
...(() => {
|
||||
switch (themeType) {
|
||||
case "login":
|
||||
return LOGIN_THEME_PAGE_IDS;
|
||||
case "account":
|
||||
return isForAccountSpa ? ["index.ftl"] : ACCOUNT_THEME_PAGE_IDS;
|
||||
}
|
||||
})(),
|
||||
...(isForAccountSpa
|
||||
? []
|
||||
: readExtraPagesNames({
|
||||
themeType,
|
||||
themeSrcDirPath: buildContext.themeSrcDirPath
|
||||
}))
|
||||
].forEach(pageId => {
|
||||
const { ftlCode } = generateFtlFilesCode({ pageId });
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(themeTypeDirPath, pageId),
|
||||
Buffer.from(ftlCode, "utf8")
|
||||
);
|
||||
});
|
||||
|
||||
i18n_messages_generation: {
|
||||
if (isForAccountSpa) {
|
||||
break i18n_messages_generation;
|
||||
}
|
||||
|
||||
generateMessageProperties({
|
||||
themeSrcDirPath: buildContext.themeSrcDirPath,
|
||||
themeType
|
||||
}).forEach(({ languageTag, propertiesFileSource }) => {
|
||||
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
|
||||
|
||||
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), {
|
||||
recursive: true
|
||||
});
|
||||
|
||||
const propertiesFilePath = pathJoin(
|
||||
messagesDirPath,
|
||||
`messages_${languageTag}.properties`
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
propertiesFilePath,
|
||||
Buffer.from(propertiesFileSource, "utf8")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
bring_in_account_v3_i18n_messages: {
|
||||
if (!buildContext.implementedThemeTypes.account.isImplemented) {
|
||||
break bring_in_account_v3_i18n_messages;
|
||||
}
|
||||
if (buildContext.implementedThemeTypes.account.type !== "Single-Page") {
|
||||
break bring_in_account_v3_i18n_messages;
|
||||
}
|
||||
|
||||
const accountUiDirPath = child_process
|
||||
.execSync("npm list @keycloakify/keycloak-account-ui --parseable", {
|
||||
cwd: pathDirname(buildContext.packageJsonFilePath)
|
||||
})
|
||||
.toString("utf8")
|
||||
.trim();
|
||||
|
||||
const messagesDirPath = pathJoin(accountUiDirPath, "messages");
|
||||
|
||||
if (!fs.existsSync(messagesDirPath)) {
|
||||
throw new Error(
|
||||
`Please update @keycloakify/keycloak-account-ui to 25.0.4-rc.5 or later.`
|
||||
);
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: messagesDirPath,
|
||||
destDirPath: pathJoin(
|
||||
getThemeTypeDirPath({ themeType: "account" }),
|
||||
"messages"
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
keycloak_static_resources: {
|
||||
if (isForAccountSpa) {
|
||||
break keycloak_static_resources;
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: pathJoin(
|
||||
getThisCodebaseRootDirPath(),
|
||||
"res",
|
||||
"public",
|
||||
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES,
|
||||
themeType
|
||||
),
|
||||
destDirPath: pathJoin(themeTypeDirPath, "resources")
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
pathJoin(themeTypeDirPath, "theme.properties"),
|
||||
Buffer.from(
|
||||
[
|
||||
`parent=${(() => {
|
||||
switch (themeType) {
|
||||
case "account":
|
||||
return isForAccountSpa ? "base" : "account-v1";
|
||||
case "login":
|
||||
return "keycloak";
|
||||
}
|
||||
assert<Equals<typeof themeType, never>>(false);
|
||||
})()}`,
|
||||
...(isForAccountSpa ? ["deprecatedMode=false"] : []),
|
||||
...(buildContext.extraThemeProperties ?? []),
|
||||
...buildContext.environmentVariables.map(
|
||||
({ name, default: defaultValue }) =>
|
||||
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
|
||||
)
|
||||
].join("\n\n"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
email: {
|
||||
if (!buildContext.implementedThemeTypes.email.isImplemented) {
|
||||
break email;
|
||||
}
|
||||
|
||||
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: emailThemeSrcDirPath,
|
||||
destDirPath: getThemeTypeDirPath({ themeType: "email" })
|
||||
});
|
||||
}
|
||||
|
||||
bring_in_account_v1: {
|
||||
if (!buildContext.implementedThemeTypes.account.isImplemented) {
|
||||
break bring_in_account_v1;
|
||||
}
|
||||
|
||||
if (buildContext.implementedThemeTypes.account.type !== "Multi-Page") {
|
||||
break bring_in_account_v1;
|
||||
}
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "res", "account-v1"),
|
||||
destDirPath: pathJoin(resourcesDirPath, "theme", "account-v1", "account")
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const metaInfKeycloakThemes: MetaInfKeycloakTheme = { themes: [] };
|
||||
|
||||
metaInfKeycloakThemes.themes.push({
|
||||
name: themeName,
|
||||
types: objectEntries(buildContext.implementedThemeTypes)
|
||||
.filter(([, { isImplemented }]) => isImplemented)
|
||||
.map(([themeType]) => themeType)
|
||||
});
|
||||
|
||||
if (buildContext.implementedThemeTypes.account.isImplemented) {
|
||||
metaInfKeycloakThemes.themes.push({
|
||||
name: "account-v1",
|
||||
types: ["account"]
|
||||
});
|
||||
}
|
||||
|
||||
writeMetaInfKeycloakThemes({
|
||||
resourcesDirPath,
|
||||
getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
import { join as pathJoin, extname as pathExtname, sep as pathSep } from "path";
|
||||
import { transformCodebase } from "../../tools/transformCodebase";
|
||||
import type { BuildContext } from "../../shared/buildContext";
|
||||
import { writeMetaInfKeycloakThemes } from "../../shared/metaInfKeycloakThemes";
|
||||
import { assert } from "tsafe/assert";
|
||||
|
||||
export type BuildContextLike = {
|
||||
keycloakifyBuildDirPath: string;
|
||||
};
|
||||
|
||||
assert<BuildContext extends BuildContextLike ? true : false>();
|
||||
|
||||
export function generateResourcesForThemeVariant(params: {
|
||||
resourcesDirPath: string;
|
||||
themeName: string;
|
||||
themeVariantName: string;
|
||||
}) {
|
||||
const { resourcesDirPath, themeName, themeVariantName } = params;
|
||||
|
||||
const mainThemeDirPath = pathJoin(resourcesDirPath, "theme", themeName);
|
||||
|
||||
transformCodebase({
|
||||
srcDirPath: mainThemeDirPath,
|
||||
destDirPath: pathJoin(mainThemeDirPath, "..", themeVariantName),
|
||||
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
|
||||
if (
|
||||
pathExtname(fileRelativePath) === ".ftl" &&
|
||||
fileRelativePath.split(pathSep).length === 2
|
||||
) {
|
||||
const modifiedSourceCode = Buffer.from(
|
||||
Buffer.from(sourceCode)
|
||||
.toString("utf-8")
|
||||
.replace(
|
||||
`"themeName": "${themeName}"`,
|
||||
`"themeName": "${themeVariantName}"`
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert(Buffer.compare(modifiedSourceCode, sourceCode) !== 0);
|
||||
|
||||
return { modifiedSourceCode };
|
||||
}
|
||||
|
||||
return { modifiedSourceCode: sourceCode };
|
||||
}
|
||||
});
|
||||
|
||||
writeMetaInfKeycloakThemes({
|
||||
resourcesDirPath,
|
||||
getNewMetaInfKeycloakTheme: ({ metaInfKeycloakTheme }) => {
|
||||
assert(metaInfKeycloakTheme !== undefined);
|
||||
|
||||
const newMetaInfKeycloakTheme = metaInfKeycloakTheme;
|
||||
|
||||
newMetaInfKeycloakTheme.themes.push({
|
||||
name: themeVariantName,
|
||||
types: (() => {
|
||||
const theme = newMetaInfKeycloakTheme.themes.find(
|
||||
({ name }) => name === themeName
|
||||
);
|
||||
assert(theme !== undefined);
|
||||
return theme.types;
|
||||
})()
|
||||
});
|
||||
|
||||
return newMetaInfKeycloakTheme;
|
||||
}
|
||||
});
|
||||
}
|
@ -3,7 +3,7 @@ export type KeycloakVersionRange =
|
||||
| KeycloakVersionRange.WithoutAccountV1Theme;
|
||||
|
||||
export namespace KeycloakVersionRange {
|
||||
export type WithoutAccountV1Theme = "22-to-25" | "all-other-versions";
|
||||
export type WithoutAccountV1Theme = "21-and-below" | "22-and-above";
|
||||
|
||||
export type WithAccountV1Theme = "21-and-below" | "23" | "24" | "25" | "26-and-above";
|
||||
export type WithAccountV1Theme = "21-and-below" | "23" | "24" | "25-and-above";
|
||||
}
|
||||
|
@ -25,7 +25,6 @@ import { type ThemeType } from "./constants";
|
||||
import { id } from "tsafe/id";
|
||||
import chalk from "chalk";
|
||||
import { getProxyFetchOptions, type ProxyFetchOptions } from "../tools/fetchProxyOptions";
|
||||
import { is } from "tsafe/is";
|
||||
|
||||
export type BuildContext = {
|
||||
themeVersion: string;
|
||||
@ -241,7 +240,8 @@ export function getBuildContext(params: {
|
||||
|
||||
if (
|
||||
parsedPackageJson.dependencies?.keycloakify === undefined &&
|
||||
parsedPackageJson.devDependencies?.keycloakify === undefined
|
||||
parsedPackageJson.devDependencies?.keycloakify === undefined &&
|
||||
parsedPackageJson.name !== "keycloakify" // NOTE: For local storybook build
|
||||
) {
|
||||
break success;
|
||||
}
|
||||
@ -277,8 +277,7 @@ export function getBuildContext(params: {
|
||||
"21-and-below": z.union([z.boolean(), z.string()]),
|
||||
"23": z.union([z.boolean(), z.string()]),
|
||||
"24": z.union([z.boolean(), z.string()]),
|
||||
"25": z.union([z.boolean(), z.string()]),
|
||||
"26-and-above": z.union([z.boolean(), z.string()])
|
||||
"25-and-above": z.union([z.boolean(), z.string()])
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
@ -299,8 +298,8 @@ export function getBuildContext(params: {
|
||||
]),
|
||||
keycloakVersionTargets: z
|
||||
.object({
|
||||
"22-to-25": z.union([z.boolean(), z.string()]),
|
||||
"all-other-versions": z.union([z.boolean(), z.string()])
|
||||
"21-and-below": z.union([z.boolean(), z.string()]),
|
||||
"22-and-above": z.union([z.boolean(), z.string()])
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
@ -471,44 +470,26 @@ export function getBuildContext(params: {
|
||||
}
|
||||
|
||||
const themeNames = ((): [string, ...string[]] => {
|
||||
const themeNames = ((): [string, ...string[]] => {
|
||||
if (buildOptions.themeName === undefined) {
|
||||
return parsedPackageJson.name === undefined
|
||||
? ["keycloakify"]
|
||||
: [
|
||||
parsedPackageJson.name
|
||||
.replace(/^@(.*)/, "$1")
|
||||
.split("/")
|
||||
.join("-")
|
||||
];
|
||||
}
|
||||
|
||||
if (typeof buildOptions.themeName === "string") {
|
||||
return [buildOptions.themeName];
|
||||
}
|
||||
|
||||
const [mainThemeName, ...themeVariantNames] = buildOptions.themeName;
|
||||
|
||||
assert(mainThemeName !== undefined);
|
||||
|
||||
return [mainThemeName, ...themeVariantNames];
|
||||
})();
|
||||
|
||||
for (const themeName of themeNames) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(themeName)) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
[
|
||||
`Invalid theme name: ${themeName}`,
|
||||
`Theme names should only contain letters, numbers, and "_" or "-"`
|
||||
].join(" ")
|
||||
)
|
||||
);
|
||||
process.exit(-1);
|
||||
}
|
||||
if (buildOptions.themeName === undefined) {
|
||||
return parsedPackageJson.name === undefined
|
||||
? ["keycloakify"]
|
||||
: [
|
||||
parsedPackageJson.name
|
||||
.replace(/^@(.*)/, "$1")
|
||||
.split("/")
|
||||
.join("-")
|
||||
];
|
||||
}
|
||||
|
||||
return themeNames;
|
||||
if (typeof buildOptions.themeName === "string") {
|
||||
return [buildOptions.themeName];
|
||||
}
|
||||
|
||||
const [mainThemeName, ...themeVariantNames] = buildOptions.themeName;
|
||||
|
||||
assert(mainThemeName !== undefined);
|
||||
|
||||
return [mainThemeName, ...themeVariantNames];
|
||||
})();
|
||||
|
||||
const projectBuildDirPath = (() => {
|
||||
@ -768,11 +749,7 @@ export function getBuildContext(params: {
|
||||
return "24" as const;
|
||||
}
|
||||
|
||||
if (buildForKeycloakMajorVersionNumber === 25) {
|
||||
return "25" as const;
|
||||
}
|
||||
|
||||
return "26-and-above" as const;
|
||||
return "25-and-above" as const;
|
||||
})();
|
||||
|
||||
assert<
|
||||
@ -785,14 +762,11 @@ export function getBuildContext(params: {
|
||||
return keycloakVersionRange;
|
||||
} else {
|
||||
const keycloakVersionRange = (() => {
|
||||
if (
|
||||
buildForKeycloakMajorVersionNumber <= 21 ||
|
||||
buildForKeycloakMajorVersionNumber >= 26
|
||||
) {
|
||||
return "all-other-versions" as const;
|
||||
if (buildForKeycloakMajorVersionNumber <= 21) {
|
||||
return "21-and-below" as const;
|
||||
}
|
||||
|
||||
return "22-to-25" as const;
|
||||
return "22-and-above" as const;
|
||||
})();
|
||||
|
||||
assert<
|
||||
@ -810,12 +784,6 @@ export function getBuildContext(params: {
|
||||
use_custom_jar_basename: {
|
||||
const { keycloakVersionTargets } = buildOptions;
|
||||
|
||||
assert(
|
||||
is<Record<KeycloakVersionRange, string | boolean>>(
|
||||
keycloakVersionTargets
|
||||
)
|
||||
);
|
||||
|
||||
if (keycloakVersionTargets === undefined) {
|
||||
break use_custom_jar_basename;
|
||||
}
|
||||
@ -860,8 +828,7 @@ export function getBuildContext(params: {
|
||||
"21-and-below",
|
||||
"23",
|
||||
"24",
|
||||
"25",
|
||||
"26-and-above"
|
||||
"25-and-above"
|
||||
] as const) {
|
||||
assert<
|
||||
Equals<
|
||||
@ -877,8 +844,8 @@ export function getBuildContext(params: {
|
||||
}
|
||||
} else {
|
||||
for (const keycloakVersionRange of [
|
||||
"22-to-25",
|
||||
"all-other-versions"
|
||||
"21-and-below",
|
||||
"22-and-above"
|
||||
] as const) {
|
||||
assert<
|
||||
Equals<
|
||||
@ -904,17 +871,7 @@ export function getBuildContext(params: {
|
||||
const jarTargets: BuildContext["jarTargets"] = [];
|
||||
|
||||
for (const [keycloakVersionRange, jarNameOrBoolean] of objectEntries(
|
||||
(() => {
|
||||
const { keycloakVersionTargets } = buildOptions;
|
||||
|
||||
assert(
|
||||
is<Record<KeycloakVersionRange, string | boolean>>(
|
||||
keycloakVersionTargets
|
||||
)
|
||||
);
|
||||
|
||||
return keycloakVersionTargets;
|
||||
})()
|
||||
buildOptions.keycloakVersionTargets
|
||||
)) {
|
||||
if (jarNameOrBoolean === false) {
|
||||
continue;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,252 +0,0 @@
|
||||
import { DOMPurify } from "keycloakify/tools/vendor/dompurify";
|
||||
|
||||
type TagType = {
|
||||
name: string;
|
||||
attributes: AttributeType[];
|
||||
};
|
||||
type AttributeType = {
|
||||
name: string;
|
||||
matchRegex?: RegExp;
|
||||
matchFunction?: (value: string) => boolean;
|
||||
};
|
||||
|
||||
// implementation for org.owasp.html.HtmlPolicyBuilder
|
||||
// https://www.javadoc.io/static/com.googlecode.owasp-java-html-sanitizer/owasp-java-html-sanitizer/20160628.1/index.html?org/owasp/html/HtmlPolicyBuilder.html
|
||||
// It supports the methods that KCSanitizerPolicy needs and nothing more
|
||||
|
||||
export class HtmlPolicyBuilder {
|
||||
private globalAttributesAllowed: Set<AttributeType> = new Set();
|
||||
private tagsAllowed: Map<string, TagType> = new Map();
|
||||
private tagsAllowedWithNoAttribute: Set<string> = new Set();
|
||||
private currentAttribute: AttributeType | null = null;
|
||||
private isStylingAllowed: boolean = false;
|
||||
private allowedProtocols: Set<string> = new Set();
|
||||
private enforceRelNofollow: boolean = false;
|
||||
private DOMPurify: typeof DOMPurify;
|
||||
|
||||
// add a constructor
|
||||
constructor(
|
||||
dependencyInjections: Partial<{
|
||||
DOMPurify: typeof DOMPurify;
|
||||
}>
|
||||
) {
|
||||
this.DOMPurify = dependencyInjections.DOMPurify ?? DOMPurify;
|
||||
}
|
||||
|
||||
allowWithoutAttributes(tag: string): this {
|
||||
this.tagsAllowedWithNoAttribute.add(tag);
|
||||
return this;
|
||||
}
|
||||
|
||||
// Adds the attributes for validation
|
||||
allowAttributes(...args: string[]): this {
|
||||
if (args.length) {
|
||||
const attr = args[0];
|
||||
this.currentAttribute = { name: attr }; // Default regex, will be set later
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// Matching regex for value of allowed attributes
|
||||
matching(matchingPattern: RegExp | ((value: string) => boolean)): this {
|
||||
if (this.currentAttribute) {
|
||||
if (matchingPattern instanceof RegExp) {
|
||||
this.currentAttribute.matchRegex = matchingPattern;
|
||||
} else {
|
||||
this.currentAttribute.matchFunction = matchingPattern;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// Make attributes in prev call global
|
||||
globally(): this {
|
||||
if (this.currentAttribute) {
|
||||
this.currentAttribute.matchRegex = /.*/;
|
||||
this.globalAttributesAllowed.add(this.currentAttribute);
|
||||
this.currentAttribute = null; // Reset after global application
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// Allow styling globally
|
||||
allowStyling(): this {
|
||||
this.isStylingAllowed = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
// Save attributes for specific tag
|
||||
onElements(...tags: string[]): this {
|
||||
if (this.currentAttribute) {
|
||||
tags.forEach(tag => {
|
||||
const element = this.tagsAllowed.get(tag) || {
|
||||
name: tag,
|
||||
attributes: []
|
||||
};
|
||||
element.attributes.push(this.currentAttribute!);
|
||||
this.tagsAllowed.set(tag, element);
|
||||
});
|
||||
this.currentAttribute = null; // Reset after applying to elements
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// Make specific tag allowed
|
||||
allowElements(...tags: string[]): this {
|
||||
tags.forEach(tag => {
|
||||
if (!this.tagsAllowed.has(tag)) {
|
||||
this.tagsAllowed.set(tag, { name: tag, attributes: [] });
|
||||
}
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
// Handle rel=nofollow on links
|
||||
requireRelNofollowOnLinks(): this {
|
||||
this.enforceRelNofollow = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
// Allow standard URL protocols (could include further implementation)
|
||||
allowStandardUrlProtocols(): this {
|
||||
this.allowedProtocols.add("http");
|
||||
this.allowedProtocols.add("https");
|
||||
this.allowedProtocols.add("mailto");
|
||||
return this;
|
||||
}
|
||||
|
||||
apply(html: string): string {
|
||||
//Clear all previous configs first ( in case we used DOMPurify somewhere else )
|
||||
this.DOMPurify.clearConfig();
|
||||
this.DOMPurify.removeAllHooks();
|
||||
this.setupHooks();
|
||||
return this.DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: Array.from(this.tagsAllowed.keys()),
|
||||
ALLOWED_ATTR: this.getAllowedAttributes(),
|
||||
ALLOWED_URI_REGEXP: this.getAllowedUriRegexp(),
|
||||
ADD_TAGS: this.isStylingAllowed ? ["style"] : [],
|
||||
ADD_ATTR: this.isStylingAllowed ? ["style"] : []
|
||||
});
|
||||
}
|
||||
|
||||
private setupHooks(): void {
|
||||
// Check allowed attribute and global attributes and it doesnt exist in them remove it
|
||||
this.DOMPurify.addHook("uponSanitizeAttribute", (currentNode, hookEvent) => {
|
||||
if (!hookEvent) return;
|
||||
|
||||
const tagName = currentNode.tagName.toLowerCase();
|
||||
const allowedAttributes = this.tagsAllowed.get(tagName)?.attributes || [];
|
||||
|
||||
//Add global attributes to allowed attributes
|
||||
this.globalAttributesAllowed.forEach(attribute => {
|
||||
allowedAttributes.push(attribute);
|
||||
});
|
||||
|
||||
//Add style attribute to allowed attributes
|
||||
if (this.isStylingAllowed) {
|
||||
let styleAttribute: AttributeType = { name: "style", matchRegex: /.*/ };
|
||||
allowedAttributes.push(styleAttribute);
|
||||
}
|
||||
|
||||
// Check if the attribute is allowed
|
||||
if (!allowedAttributes.some(attr => attr.name === hookEvent.attrName)) {
|
||||
hookEvent.forceKeepAttr = false;
|
||||
hookEvent.keepAttr = false;
|
||||
currentNode.removeAttribute(hookEvent.attrName);
|
||||
return;
|
||||
} else {
|
||||
const attributeType = allowedAttributes.find(
|
||||
attr => attr.name === hookEvent.attrName
|
||||
);
|
||||
if (attributeType) {
|
||||
//Check if attribute value is allowed
|
||||
if (
|
||||
attributeType.matchRegex &&
|
||||
!attributeType.matchRegex.test(hookEvent.attrValue)
|
||||
) {
|
||||
hookEvent.forceKeepAttr = false;
|
||||
hookEvent.keepAttr = false;
|
||||
currentNode.removeAttribute(hookEvent.attrName);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
attributeType.matchFunction &&
|
||||
!attributeType.matchFunction(hookEvent.attrValue)
|
||||
) {
|
||||
hookEvent.forceKeepAttr = false;
|
||||
hookEvent.keepAttr = false;
|
||||
currentNode.removeAttribute(hookEvent.attrName);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// both attribute and value already checked so they should be ok
|
||||
// set forceKeep to true to make sure next hooks won't delete them
|
||||
// except for href that we will check later
|
||||
if (hookEvent.attrName !== "href") {
|
||||
hookEvent.keepAttr = true;
|
||||
hookEvent.forceKeepAttr = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.DOMPurify.addHook("afterSanitizeAttributes", currentNode => {
|
||||
// if tag is not allowed to have no attribute then remove it completely
|
||||
if (
|
||||
currentNode.attributes.length == 0 &&
|
||||
currentNode.childNodes.length == 0
|
||||
) {
|
||||
if (!this.tagsAllowedWithNoAttribute.has(currentNode.tagName)) {
|
||||
currentNode.remove();
|
||||
}
|
||||
} else {
|
||||
//in case of <a> or <img> if we have no attribute we need to remove them even if they have child
|
||||
if (currentNode.tagName === "A" || currentNode.tagName === "IMG") {
|
||||
if (currentNode.attributes.length == 0) {
|
||||
//add currentNode children to parent node
|
||||
while (currentNode.firstChild) {
|
||||
currentNode?.parentNode?.insertBefore(
|
||||
currentNode.firstChild,
|
||||
currentNode
|
||||
);
|
||||
}
|
||||
// Remove the currentNode itself
|
||||
currentNode.remove();
|
||||
}
|
||||
}
|
||||
//
|
||||
if (currentNode.tagName === "A") {
|
||||
if (this.enforceRelNofollow) {
|
||||
if (!currentNode.hasAttribute("rel")) {
|
||||
currentNode.setAttribute("rel", "nofollow");
|
||||
} else if (
|
||||
!currentNode.getAttribute("rel")?.includes("nofollow")
|
||||
) {
|
||||
currentNode.setAttribute(
|
||||
"rel",
|
||||
currentNode.getAttribute("rel") + " nofollow"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getAllowedAttributes(): string[] {
|
||||
const allowedAttributes: Set<string> = new Set();
|
||||
this.tagsAllowed.forEach(element => {
|
||||
element.attributes.forEach(attribute => {
|
||||
allowedAttributes.add(attribute.name);
|
||||
});
|
||||
});
|
||||
this.globalAttributesAllowed.forEach(attribute => {
|
||||
allowedAttributes.add(attribute.name);
|
||||
});
|
||||
return Array.from(allowedAttributes);
|
||||
}
|
||||
|
||||
private getAllowedUriRegexp(): RegExp {
|
||||
const protocols = Array.from(this.allowedProtocols).join("|");
|
||||
return new RegExp(`^(?:${protocols})://`, "i");
|
||||
}
|
||||
}
|
@ -1,60 +0,0 @@
|
||||
import { KcSanitizerPolicy } from "./KcSanitizerPolicy";
|
||||
import type { DOMPurify as ofTypeDomPurify } from "keycloakify/tools/vendor/dompurify";
|
||||
|
||||
// implementation of keycloak java sanitize method ( KeycloakSanitizerMethod )
|
||||
// https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/main/java/org/keycloak/theme/KeycloakSanitizerMethod.java#L33
|
||||
export class KcSanitizer {
|
||||
private static HREF_PATTERN = /\s+href="([^"]*)"/g;
|
||||
private static textarea: HTMLTextAreaElement | null = null;
|
||||
|
||||
public static sanitize(
|
||||
html: string,
|
||||
dependencyInjections: Partial<{
|
||||
DOMPurify: typeof ofTypeDomPurify;
|
||||
htmlEntitiesDecode: (html: string) => string;
|
||||
}>
|
||||
): string {
|
||||
if (html === "") return "";
|
||||
|
||||
html =
|
||||
dependencyInjections?.htmlEntitiesDecode !== undefined
|
||||
? dependencyInjections.htmlEntitiesDecode(html)
|
||||
: this.decodeHtml(html);
|
||||
const sanitized = KcSanitizerPolicy.sanitize(html, dependencyInjections);
|
||||
return this.fixURLs(sanitized);
|
||||
}
|
||||
|
||||
private static decodeHtml(html: string): string {
|
||||
if (!KcSanitizer.textarea) {
|
||||
KcSanitizer.textarea = document.createElement("textarea");
|
||||
}
|
||||
KcSanitizer.textarea.innerHTML = html;
|
||||
return KcSanitizer.textarea.value;
|
||||
}
|
||||
|
||||
// This will remove unwanted characters from url
|
||||
private static fixURLs(msg: string): string {
|
||||
const HREF_PATTERN = this.HREF_PATTERN;
|
||||
const result = [];
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
do {
|
||||
match = HREF_PATTERN.exec(msg);
|
||||
if (match) {
|
||||
const href = match[0]
|
||||
.replace(/=/g, "=")
|
||||
.replace(/\.\./g, ".")
|
||||
.replace(/&/g, "&");
|
||||
|
||||
result.push(msg.substring(last, match.index!));
|
||||
result.push(href);
|
||||
|
||||
last = HREF_PATTERN.lastIndex;
|
||||
}
|
||||
} while (match);
|
||||
|
||||
result.push(msg.substring(last));
|
||||
return result.join("");
|
||||
}
|
||||
}
|
@ -1,294 +0,0 @@
|
||||
import { HtmlPolicyBuilder } from "./HtmlPolicyBuilder";
|
||||
import type { DOMPurify as ofTypeDomPurify } from "keycloakify/tools/vendor/dompurify";
|
||||
|
||||
//implementation of java Sanitizer policy ( KeycloakSanitizerPolicy )
|
||||
// All regex directly copied from the keycloak source but some of them changed slightly to work with typescript(ONSITE_URL and OFFSITE_URL)
|
||||
// Also replaced ?i with "i" tag as second parameter of RegExp
|
||||
//https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/main/java/org/keycloak/theme/KeycloakSanitizerPolicy.java#L29
|
||||
export class KcSanitizerPolicy {
|
||||
public static readonly COLOR_NAME = new RegExp(
|
||||
"(?:aqua|black|blue|fuchsia|gray|grey|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)"
|
||||
);
|
||||
|
||||
public static readonly COLOR_CODE = new RegExp(
|
||||
"(?:#(?:[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?))"
|
||||
);
|
||||
|
||||
public static readonly NUMBER_OR_PERCENT = new RegExp("[0-9]+%?");
|
||||
|
||||
public static readonly PARAGRAPH = new RegExp(
|
||||
"(?:[\\p{L}\\p{N},'\\.\\s\\-_\\(\\)]|&[0-9]{2};)*",
|
||||
"u" // Unicode flag for \p{L} and \p{N} in the pattern
|
||||
);
|
||||
|
||||
public static readonly HTML_ID = new RegExp("[a-zA-Z0-9\\:\\-_\\.]+");
|
||||
|
||||
public static readonly HTML_TITLE = new RegExp(
|
||||
"[\\p{L}\\p{N}\\s\\-_',:\\[\\]!\\./\\\\\\(\\)&]*",
|
||||
"u" // Unicode flag for \p{L} and \p{N} in the pattern
|
||||
);
|
||||
|
||||
public static readonly HTML_CLASS = new RegExp("[a-zA-Z0-9\\s,\\-_]+");
|
||||
|
||||
public static readonly ONSITE_URL = new RegExp(
|
||||
"(?:[\\p{L}\\p{N}.#@\\$%+&;\\-_~,?=/!]+|#(\\w)+)",
|
||||
"u" // Unicode flag for \p{L} and \p{N} in the pattern
|
||||
);
|
||||
|
||||
public static readonly OFFSITE_URL = new RegExp(
|
||||
"\\s*(?:(?:ht|f)tps?://|mailto:)[\\p{L}\\p{N}]+" +
|
||||
"[\\p{L}\\p{N}\\p{Zs}.#@\\$%+&;:\\-_~,?=/!()]*\\s*",
|
||||
"u" // Unicode flag for \p{L} and \p{N} in the pattern
|
||||
);
|
||||
|
||||
public static readonly NUMBER = new RegExp(
|
||||
"[+-]?(?:(?:[0-9]+(?:\\.[0-9]*)?)|\\.[0-9]+)"
|
||||
);
|
||||
public static readonly NAME = new RegExp("[a-zA-Z0-9\\-_\\$]+");
|
||||
|
||||
public static readonly ALIGN = new RegExp(
|
||||
"\\b(center|left|right|justify|char)\\b",
|
||||
"i" // Case-insensitive flag
|
||||
);
|
||||
|
||||
public static readonly VALIGN = new RegExp(
|
||||
"\\b(baseline|bottom|middle|top)\\b",
|
||||
"i" // Case-insensitive flag
|
||||
);
|
||||
|
||||
public static readonly HISTORY_BACK = new RegExp(
|
||||
"(?:javascript:)?\\Qhistory.go(-1)\\E"
|
||||
);
|
||||
|
||||
public static readonly ONE_CHAR = new RegExp(
|
||||
".?",
|
||||
"s" // Dotall flag for . to match newlines
|
||||
);
|
||||
|
||||
private static COLOR_NAME_OR_COLOR_CODE(s: string): boolean {
|
||||
return (
|
||||
KcSanitizerPolicy.COLOR_NAME.test(s) || KcSanitizerPolicy.COLOR_CODE.test(s)
|
||||
);
|
||||
}
|
||||
|
||||
private static ONSITE_OR_OFFSITE_URL(s: string): boolean {
|
||||
return (
|
||||
KcSanitizerPolicy.ONSITE_URL.test(s) || KcSanitizerPolicy.OFFSITE_URL.test(s)
|
||||
);
|
||||
}
|
||||
|
||||
public static sanitize(
|
||||
html: string,
|
||||
dependencyInjections: Partial<{
|
||||
DOMPurify: typeof ofTypeDomPurify;
|
||||
}>
|
||||
): string {
|
||||
return new HtmlPolicyBuilder(dependencyInjections)
|
||||
.allowWithoutAttributes("span")
|
||||
|
||||
.allowAttributes("id")
|
||||
.matching(this.HTML_ID)
|
||||
.globally()
|
||||
|
||||
.allowAttributes("class")
|
||||
.matching(this.HTML_CLASS)
|
||||
.globally()
|
||||
|
||||
.allowAttributes("lang")
|
||||
.matching(/[a-zA-Z]{2,20}/)
|
||||
.globally()
|
||||
|
||||
.allowAttributes("title")
|
||||
.matching(this.HTML_TITLE)
|
||||
.globally()
|
||||
|
||||
.allowStyling()
|
||||
|
||||
.allowAttributes("align")
|
||||
.matching(this.ALIGN)
|
||||
.onElements("p")
|
||||
|
||||
.allowAttributes("for")
|
||||
.matching(this.HTML_ID)
|
||||
.onElements("label")
|
||||
|
||||
.allowAttributes("color")
|
||||
.matching(this.COLOR_NAME_OR_COLOR_CODE)
|
||||
.onElements("font")
|
||||
|
||||
.allowAttributes("face")
|
||||
.matching(/[\w;, \-]+/)
|
||||
.onElements("font")
|
||||
|
||||
.allowAttributes("size")
|
||||
.matching(this.NUMBER)
|
||||
.onElements("font")
|
||||
|
||||
.allowAttributes("href")
|
||||
.matching(this.ONSITE_OR_OFFSITE_URL)
|
||||
.onElements("a")
|
||||
|
||||
.allowStandardUrlProtocols()
|
||||
.allowAttributes("nohref")
|
||||
.onElements("a")
|
||||
|
||||
.allowAttributes("name")
|
||||
.matching(this.NAME)
|
||||
.onElements("a")
|
||||
|
||||
.allowAttributes("onfocus", "onblur", "onclick", "onmousedown", "onmouseup")
|
||||
.matching(this.HISTORY_BACK)
|
||||
.onElements("a")
|
||||
|
||||
.requireRelNofollowOnLinks()
|
||||
.allowAttributes("src")
|
||||
.matching(this.ONSITE_OR_OFFSITE_URL)
|
||||
.onElements("img")
|
||||
|
||||
.allowAttributes("name")
|
||||
.matching(this.NAME)
|
||||
.onElements("img")
|
||||
|
||||
.allowAttributes("alt")
|
||||
.matching(this.PARAGRAPH)
|
||||
.onElements("img")
|
||||
|
||||
.allowAttributes("border", "hspace", "vspace")
|
||||
.matching(this.NUMBER)
|
||||
.onElements("img")
|
||||
|
||||
.allowAttributes("border", "cellpadding", "cellspacing")
|
||||
.matching(this.NUMBER)
|
||||
.onElements("table")
|
||||
|
||||
.allowAttributes("bgcolor")
|
||||
.matching(this.COLOR_NAME_OR_COLOR_CODE)
|
||||
.onElements("table")
|
||||
|
||||
.allowAttributes("background")
|
||||
.matching(this.ONSITE_URL)
|
||||
.onElements("table")
|
||||
|
||||
.allowAttributes("align")
|
||||
.matching(this.ALIGN)
|
||||
.onElements("table")
|
||||
|
||||
.allowAttributes("noresize")
|
||||
.matching(new RegExp("noresize", "i"))
|
||||
.onElements("table")
|
||||
|
||||
.allowAttributes("background")
|
||||
.matching(this.ONSITE_URL)
|
||||
.onElements("td", "th", "tr")
|
||||
|
||||
.allowAttributes("bgcolor")
|
||||
.matching(this.COLOR_NAME_OR_COLOR_CODE)
|
||||
.onElements("td", "th")
|
||||
|
||||
.allowAttributes("abbr")
|
||||
.matching(this.PARAGRAPH)
|
||||
.onElements("td", "th")
|
||||
|
||||
.allowAttributes("axis", "headers")
|
||||
.matching(this.NAME)
|
||||
.onElements("td", "th")
|
||||
|
||||
.allowAttributes("scope")
|
||||
.matching(new RegExp("(?:row|col)(?:group)?", "i"))
|
||||
.onElements("td", "th")
|
||||
|
||||
.allowAttributes("nowrap")
|
||||
.onElements("td", "th")
|
||||
|
||||
.allowAttributes("height", "width")
|
||||
.matching(this.NUMBER_OR_PERCENT)
|
||||
.onElements("table", "td", "th", "tr", "img")
|
||||
|
||||
.allowAttributes("align")
|
||||
.matching(this.ALIGN)
|
||||
.onElements(
|
||||
"thead",
|
||||
"tbody",
|
||||
"tfoot",
|
||||
"img",
|
||||
"td",
|
||||
"th",
|
||||
"tr",
|
||||
"colgroup",
|
||||
"col"
|
||||
)
|
||||
|
||||
.allowAttributes("valign")
|
||||
.matching(this.VALIGN)
|
||||
.onElements("thead", "tbody", "tfoot", "td", "th", "tr", "colgroup", "col")
|
||||
|
||||
.allowAttributes("charoff")
|
||||
.matching(this.NUMBER_OR_PERCENT)
|
||||
.onElements("td", "th", "tr", "colgroup", "col", "thead", "tbody", "tfoot")
|
||||
|
||||
.allowAttributes("char")
|
||||
.matching(this.ONE_CHAR)
|
||||
.onElements("td", "th", "tr", "colgroup", "col", "thead", "tbody", "tfoot")
|
||||
|
||||
.allowAttributes("colspan", "rowspan")
|
||||
.matching(this.NUMBER)
|
||||
.onElements("td", "th")
|
||||
|
||||
.allowAttributes("span", "width")
|
||||
.matching(this.NUMBER_OR_PERCENT)
|
||||
.onElements("colgroup", "col")
|
||||
.allowElements(
|
||||
"a",
|
||||
"label",
|
||||
"noscript",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"p",
|
||||
"i",
|
||||
"b",
|
||||
"u",
|
||||
"strong",
|
||||
"em",
|
||||
"small",
|
||||
"big",
|
||||
"pre",
|
||||
"code",
|
||||
"cite",
|
||||
"samp",
|
||||
"sub",
|
||||
"sup",
|
||||
"strike",
|
||||
"center",
|
||||
"blockquote",
|
||||
"hr",
|
||||
"br",
|
||||
"col",
|
||||
"font",
|
||||
"map",
|
||||
"span",
|
||||
"div",
|
||||
"img",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"dd",
|
||||
"dt",
|
||||
"dl",
|
||||
"tbody",
|
||||
"thead",
|
||||
"tfoot",
|
||||
"table",
|
||||
"td",
|
||||
"th",
|
||||
"tr",
|
||||
"colgroup",
|
||||
"fieldset",
|
||||
"legend"
|
||||
)
|
||||
.apply(html);
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import { KcSanitizer } from "./KcSanitizer";
|
||||
|
||||
export function kcSanitize(html: string): string {
|
||||
return KcSanitizer.sanitize(html, {});
|
||||
}
|
@ -2,7 +2,7 @@ import type { ThemeType, LoginThemePageId } from "keycloakify/bin/shared/constan
|
||||
import type { ValueOf } from "keycloakify/tools/ValueOf";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
import type { ClassKey } from "keycloakify/login/lib/kcClsx";
|
||||
import type { ClassKey } from "keycloakify/login/TemplateProps";
|
||||
|
||||
export type ExtendKcContext<
|
||||
KcContextExtension extends { properties?: Record<string, string | undefined> },
|
||||
|
@ -7,7 +7,6 @@ import {
|
||||
import { id } from "tsafe/id";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import { BASE_URL } from "keycloakify/lib/BASE_URL";
|
||||
import type { LanguageTag } from "keycloakify/login/i18n/messages_defaultSet/types";
|
||||
|
||||
const attributesByName = Object.fromEntries(
|
||||
id<Attribute[]>([
|
||||
@ -117,59 +116,35 @@ export const kcContextCommonMock: KcContext.Common = {
|
||||
}
|
||||
},
|
||||
locale: {
|
||||
supported: (
|
||||
[
|
||||
/* spell-checker: disable */
|
||||
["de", "Deutsch"],
|
||||
["no", "Norsk"],
|
||||
["ru", "Русский"],
|
||||
["sv", "Svenska"],
|
||||
["pt-BR", "Português (Brasil)"],
|
||||
["lt", "Lietuvių"],
|
||||
["en", "English"],
|
||||
["it", "Italiano"],
|
||||
["fr", "Français"],
|
||||
["zh-CN", "中文简体"],
|
||||
["es", "Español"],
|
||||
["cs", "Čeština"],
|
||||
["ja", "日本語"],
|
||||
["sk", "Slovenčina"],
|
||||
["pl", "Polski"],
|
||||
["ca", "Català"],
|
||||
["nl", "Nederlands"],
|
||||
["tr", "Türkçe"],
|
||||
["ar", "العربية"],
|
||||
["da", "Dansk"],
|
||||
["el", "Ελληνικά"],
|
||||
["fa", "فارسی"],
|
||||
["fi", "Suomi"],
|
||||
["hu", "Magyar"],
|
||||
["ka", "ქართული"],
|
||||
["lv", "Latviešu"],
|
||||
["pt", "Português"],
|
||||
["th", "ไทย"],
|
||||
["uk", "Українська"],
|
||||
["zh-TW", "中文繁體"]
|
||||
/* spell-checker: enable */
|
||||
] as const
|
||||
).map(([languageTag, label]) => {
|
||||
{
|
||||
type Got = typeof languageTag;
|
||||
type Expected = LanguageTag;
|
||||
|
||||
type Missing = Exclude<Expected, Got>;
|
||||
type Unexpected = Exclude<Got, Expected>;
|
||||
|
||||
assert<Equals<Missing, never>>;
|
||||
assert<Equals<Unexpected, never>>;
|
||||
}
|
||||
|
||||
return {
|
||||
languageTag,
|
||||
label,
|
||||
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
|
||||
} as const;
|
||||
}),
|
||||
supported: [
|
||||
/* spell-checker: disable */
|
||||
["de", "Deutsch"],
|
||||
["no", "Norsk"],
|
||||
["ru", "Русский"],
|
||||
["sv", "Svenska"],
|
||||
["pt-BR", "Português (Brasil)"],
|
||||
["lt", "Lietuvių"],
|
||||
["en", "English"],
|
||||
["it", "Italiano"],
|
||||
["fr", "Français"],
|
||||
["zh-CN", "中文简体"],
|
||||
["es", "Español"],
|
||||
["cs", "Čeština"],
|
||||
["ja", "日本語"],
|
||||
["sk", "Slovenčina"],
|
||||
["pl", "Polski"],
|
||||
["ca", "Català"],
|
||||
["nl", "Nederlands"],
|
||||
["tr", "Türkçe"]
|
||||
/* spell-checker: enable */
|
||||
].map(
|
||||
([languageTag, label]) =>
|
||||
({
|
||||
languageTag,
|
||||
label,
|
||||
url: "https://gist.github.com/garronej/52baaca1bb925f2296ab32741e062b8e"
|
||||
}) as const
|
||||
),
|
||||
|
||||
currentLanguageTag: "en"
|
||||
},
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import type { TemplateProps } from "keycloakify/login/TemplateProps";
|
||||
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
import { useSetClassName } from "keycloakify/tools/useSetClassName";
|
||||
import { useInitialize } from "keycloakify/login/Template.useInitialize";
|
||||
import { useStylesAndScripts } from "keycloakify/login/Template.useStylesAndScripts";
|
||||
import type { I18n } from "./i18n";
|
||||
import type { KcContext } from "./KcContext";
|
||||
|
||||
@ -27,9 +27,9 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
|
||||
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
|
||||
|
||||
const { msg, msgStr, currentLanguage, enabledLanguages } = i18n;
|
||||
const { msg, msgStr, getChangeLocaleUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n;
|
||||
|
||||
const { realm, auth, url, message, isAppInitiatedAction } = kcContext;
|
||||
const { realm, locale, auth, url, message, isAppInitiatedAction } = kcContext;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = documentTitle ?? msgStr("loginTitle", kcContext.realm.displayName);
|
||||
@ -45,7 +45,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
className: bodyClassName ?? kcClsx("kcBodyClass")
|
||||
});
|
||||
|
||||
const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss });
|
||||
const { isReadyToRender } = useStylesAndScripts({ kcContext, doUseDefaultCss });
|
||||
|
||||
if (!isReadyToRender) {
|
||||
return null;
|
||||
@ -58,9 +58,10 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
{msg("loginTitleHtml", realm.displayNameHtml)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={kcClsx("kcFormCardClass")}>
|
||||
<header className={kcClsx("kcFormHeaderClass")}>
|
||||
{enabledLanguages.length > 1 && (
|
||||
{realm.internationalizationEnabled && (assert(locale !== undefined), locale.supported.length > 1) && (
|
||||
<div className={kcClsx("kcLocaleMainClass")} id="kc-locale">
|
||||
<div id="kc-locale-wrapper" className={kcClsx("kcLocaleWrapperClass")}>
|
||||
<div id="kc-locale-dropdown" className={clsx("menu-button-links", kcClsx("kcLocaleDropDownClass"))}>
|
||||
@ -72,7 +73,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
aria-expanded="false"
|
||||
aria-controls="language-switch1"
|
||||
>
|
||||
{currentLanguage.label}
|
||||
{labelBySupportedLanguageTag[currentLanguageTag]}
|
||||
</button>
|
||||
<ul
|
||||
role="menu"
|
||||
@ -82,10 +83,15 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
id="language-switch1"
|
||||
className={kcClsx("kcLocaleListClass")}
|
||||
>
|
||||
{enabledLanguages.map(({ languageTag, label, href }, i) => (
|
||||
{locale.supported.map(({ languageTag }, i) => (
|
||||
<li key={languageTag} className={kcClsx("kcLocaleListItemClass")} role="none">
|
||||
<a role="menuitem" id={`language-${i + 1}`} className={kcClsx("kcLocaleItemClass")} href={href}>
|
||||
{label}
|
||||
<a
|
||||
role="menuitem"
|
||||
id={`language-${i + 1}`}
|
||||
className={kcClsx("kcLocaleItemClass")}
|
||||
href={getChangeLocaleUrl(languageTag)}
|
||||
>
|
||||
{labelBySupportedLanguageTag[languageTag]}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
@ -146,7 +152,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
||||
<span
|
||||
className={kcClsx("kcAlertTitleClass")}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(message.summary)
|
||||
__html: message.summary
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -2,7 +2,7 @@ import { useEffect } from "react";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
|
||||
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
|
||||
import type { KcContext } from "keycloakify/login/KcContext";
|
||||
import { KcContext } from "keycloakify/login/KcContext/KcContext";
|
||||
|
||||
export type KcContextLike = {
|
||||
url: {
|
||||
@ -10,19 +10,34 @@ export type KcContextLike = {
|
||||
resourcesPath: string;
|
||||
ssoLoginInOtherTabsUrl: string;
|
||||
};
|
||||
locale?: {
|
||||
currentLanguageTag: string;
|
||||
};
|
||||
scripts: string[];
|
||||
};
|
||||
|
||||
assert<keyof KcContextLike extends keyof KcContext ? true : false>();
|
||||
assert<KcContext extends KcContextLike ? true : false>();
|
||||
|
||||
export function useInitialize(params: {
|
||||
export function useStylesAndScripts(params: {
|
||||
kcContext: KcContextLike;
|
||||
doUseDefaultCss: boolean;
|
||||
}) {
|
||||
const { kcContext, doUseDefaultCss } = params;
|
||||
|
||||
const { url, scripts } = kcContext;
|
||||
const { url, locale, scripts } = kcContext;
|
||||
|
||||
useEffect(() => {
|
||||
const { currentLanguageTag } = locale ?? {};
|
||||
|
||||
if (currentLanguageTag === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = document.querySelector("html");
|
||||
assert(html !== null);
|
||||
html.lang = currentLanguageTag;
|
||||
}, []);
|
||||
|
||||
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
|
||||
componentOrHookName: "Template",
|
||||
@ -54,7 +69,9 @@ export function useInitialize(params: {
|
||||
textContent: `
|
||||
import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";
|
||||
|
||||
checkCookiesAndSetTimer("${url.ssoLoginInOtherTabsUrl}");
|
||||
checkCookiesAndSetTimer(
|
||||
"${url.ssoLoginInOtherTabsUrl}"
|
||||
);
|
||||
`
|
||||
}
|
||||
]
|
@ -1,5 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ClassKey } from "keycloakify/login/lib/kcClsx";
|
||||
|
||||
export type TemplateProps<KcContext, I18n> = {
|
||||
kcContext: KcContext;
|
||||
@ -19,4 +18,128 @@ export type TemplateProps<KcContext, I18n> = {
|
||||
bodyClassName?: string;
|
||||
};
|
||||
|
||||
export type { ClassKey };
|
||||
export type ClassKey =
|
||||
| "kcBodyClass"
|
||||
| "kcHeaderWrapperClass"
|
||||
| "kcLocaleWrapperClass"
|
||||
| "kcInfoAreaWrapperClass"
|
||||
| "kcFormButtonsWrapperClass"
|
||||
| "kcFormOptionsWrapperClass"
|
||||
| "kcCheckboxInputClass"
|
||||
| "kcLocaleDropDownClass"
|
||||
| "kcLocaleListItemClass"
|
||||
| "kcContentWrapperClass"
|
||||
| "kcLogoIdP-facebook"
|
||||
| "kcAuthenticatorOTPClass"
|
||||
| "kcLogoIdP-bitbucket"
|
||||
| "kcAuthenticatorWebAuthnClass"
|
||||
| "kcWebAuthnDefaultIcon"
|
||||
| "kcLogoIdP-stackoverflow"
|
||||
| "kcSelectAuthListItemClass"
|
||||
| "kcLogoIdP-microsoft"
|
||||
| "kcLoginOTPListItemHeaderClass"
|
||||
| "kcLocaleItemClass"
|
||||
| "kcLoginOTPListItemIconBodyClass"
|
||||
| "kcInputHelperTextAfterClass"
|
||||
| "kcFormClass"
|
||||
| "kcSelectAuthListClass"
|
||||
| "kcInputClassRadioCheckboxLabelDisabled"
|
||||
| "kcSelectAuthListItemIconClass"
|
||||
| "kcRecoveryCodesWarning"
|
||||
| "kcFormSettingClass"
|
||||
| "kcWebAuthnBLE"
|
||||
| "kcInputWrapperClass"
|
||||
| "kcSelectAuthListItemArrowIconClass"
|
||||
| "kcFeedbackAreaClass"
|
||||
| "kcFormPasswordVisibilityButtonClass"
|
||||
| "kcLogoIdP-google"
|
||||
| "kcCheckLabelClass"
|
||||
| "kcSelectAuthListItemFillClass"
|
||||
| "kcAuthenticatorDefaultClass"
|
||||
| "kcLogoIdP-gitlab"
|
||||
| "kcFormAreaClass"
|
||||
| "kcFormButtonsClass"
|
||||
| "kcInputClassRadioLabel"
|
||||
| "kcAuthenticatorWebAuthnPasswordlessClass"
|
||||
| "kcSelectAuthListItemHeadingClass"
|
||||
| "kcInfoAreaClass"
|
||||
| "kcLogoLink"
|
||||
| "kcContainerClass"
|
||||
| "kcSelectAuthListItemTitle"
|
||||
| "kcHtmlClass"
|
||||
| "kcLoginOTPListItemTitleClass"
|
||||
| "kcLogoIdP-openshift-v4"
|
||||
| "kcWebAuthnUnknownIcon"
|
||||
| "kcFormSocialAccountNameClass"
|
||||
| "kcLogoIdP-openshift-v3"
|
||||
| "kcLoginOTPListInputClass"
|
||||
| "kcWebAuthnUSB"
|
||||
| "kcInputClassRadio"
|
||||
| "kcWebAuthnKeyIcon"
|
||||
| "kcFeedbackInfoIcon"
|
||||
| "kcCommonLogoIdP"
|
||||
| "kcRecoveryCodesActions"
|
||||
| "kcFormGroupHeader"
|
||||
| "kcFormSocialAccountSectionClass"
|
||||
| "kcLogoIdP-instagram"
|
||||
| "kcAlertClass"
|
||||
| "kcHeaderClass"
|
||||
| "kcLabelWrapperClass"
|
||||
| "kcFormPasswordVisibilityIconShow"
|
||||
| "kcFormSocialAccountLinkClass"
|
||||
| "kcLocaleMainClass"
|
||||
| "kcInputGroup"
|
||||
| "kcTextareaClass"
|
||||
| "kcButtonBlockClass"
|
||||
| "kcButtonClass"
|
||||
| "kcWebAuthnNFC"
|
||||
| "kcLocaleClass"
|
||||
| "kcInputClassCheckboxInput"
|
||||
| "kcFeedbackErrorIcon"
|
||||
| "kcInputLargeClass"
|
||||
| "kcInputErrorMessageClass"
|
||||
| "kcRecoveryCodesList"
|
||||
| "kcFormSocialAccountListClass"
|
||||
| "kcAlertTitleClass"
|
||||
| "kcAuthenticatorPasswordClass"
|
||||
| "kcCheckInputClass"
|
||||
| "kcLogoIdP-linkedin"
|
||||
| "kcLogoIdP-twitter"
|
||||
| "kcFeedbackWarningIcon"
|
||||
| "kcResetFlowIcon"
|
||||
| "kcSelectAuthListItemIconPropertyClass"
|
||||
| "kcFeedbackSuccessIcon"
|
||||
| "kcLoginOTPListClass"
|
||||
| "kcSrOnlyClass"
|
||||
| "kcFormSocialAccountListGridClass"
|
||||
| "kcButtonDefaultClass"
|
||||
| "kcFormGroupErrorClass"
|
||||
| "kcSelectAuthListItemDescriptionClass"
|
||||
| "kcSelectAuthListItemBodyClass"
|
||||
| "kcWebAuthnInternal"
|
||||
| "kcSelectAuthListItemArrowClass"
|
||||
| "kcCheckClass"
|
||||
| "kcContentClass"
|
||||
| "kcLogoClass"
|
||||
| "kcLoginOTPListItemIconClass"
|
||||
| "kcLoginClass"
|
||||
| "kcSignUpClass"
|
||||
| "kcButtonLargeClass"
|
||||
| "kcFormCardClass"
|
||||
| "kcLocaleListClass"
|
||||
| "kcInputClass"
|
||||
| "kcFormGroupClass"
|
||||
| "kcLogoIdP-paypal"
|
||||
| "kcInputClassCheckbox"
|
||||
| "kcRecoveryCodesConfirmation"
|
||||
| "kcFormPasswordVisibilityIconHide"
|
||||
| "kcInputClassRadioInput"
|
||||
| "kcFormSocialAccountListButtonClass"
|
||||
| "kcInputClassCheckboxLabel"
|
||||
| "kcFormOptionsClass"
|
||||
| "kcFormHeaderClass"
|
||||
| "kcFormSocialAccountGridItem"
|
||||
| "kcButtonPrimaryClass"
|
||||
| "kcInputHelperTextBeforeClass"
|
||||
| "kcLogoIdP-github"
|
||||
| "kcLabelClass";
|
||||
|
@ -434,7 +434,9 @@ function AddRemoveButtonsMultiValuedAttribute(props: {
|
||||
}
|
||||
|
||||
function InputTagSelects(props: InputFieldByTypeProps) {
|
||||
const { attribute, dispatchFormAction, kcClsx, i18n, valueOrValues } = props;
|
||||
const { attribute, dispatchFormAction, kcClsx, valueOrValues } = props;
|
||||
|
||||
const { advancedMsg } = props.i18n;
|
||||
|
||||
const { classDiv, classInput, classLabel, inputType } = (() => {
|
||||
const { inputType } = attribute.annotations;
|
||||
@ -531,7 +533,7 @@ function InputTagSelects(props: InputFieldByTypeProps) {
|
||||
htmlFor={`${attribute.name}-${option}`}
|
||||
className={`${classLabel}${attribute.readOnly ? ` ${kcClsx("kcInputClassRadioCheckboxLabelDisabled")}` : ""}`}
|
||||
>
|
||||
{inputLabel(i18n, attribute, option)}
|
||||
{advancedMsg(option)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
@ -578,6 +580,8 @@ function TextareaTag(props: InputFieldByTypeProps) {
|
||||
function SelectTag(props: InputFieldByTypeProps) {
|
||||
const { attribute, dispatchFormAction, kcClsx, displayableErrors, i18n, valueOrValues } = props;
|
||||
|
||||
const { advancedMsgStr } = i18n;
|
||||
|
||||
const isMultiple = attribute.annotations.inputType === "multiselect";
|
||||
|
||||
return (
|
||||
@ -641,26 +645,22 @@ function SelectTag(props: InputFieldByTypeProps) {
|
||||
|
||||
return options.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{inputLabel(i18n, attribute, option)}
|
||||
{(() => {
|
||||
if (attribute.annotations.inputOptionLabels !== undefined) {
|
||||
const { inputOptionLabels } = attribute.annotations;
|
||||
|
||||
return advancedMsgStr(inputOptionLabels[option] ?? option);
|
||||
}
|
||||
|
||||
if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) {
|
||||
return advancedMsgStr(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
|
||||
}
|
||||
|
||||
return option;
|
||||
})()}
|
||||
</option>
|
||||
));
|
||||
})()}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function inputLabel(i18n: I18n, attribute: Attribute, option: string) {
|
||||
const { advancedMsg } = i18n;
|
||||
|
||||
if (attribute.annotations.inputOptionLabels !== undefined) {
|
||||
const { inputOptionLabels } = attribute.annotations;
|
||||
|
||||
return advancedMsg(inputOptionLabels[option] ?? option);
|
||||
}
|
||||
|
||||
if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) {
|
||||
return advancedMsg(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
|
||||
}
|
||||
|
||||
return option;
|
||||
}
|
||||
|
6
src/login/i18n/GenericI18n.tsx
Normal file
6
src/login/i18n/GenericI18n.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import type { GenericI18n_noJsx } from "./i18n";
|
||||
|
||||
export type GenericI18n<MessageKey extends string> = GenericI18n_noJsx<MessageKey> & {
|
||||
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
|
||||
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
|
||||
};
|
252
src/login/i18n/i18n.tsx
Normal file
252
src/login/i18n/i18n.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
import "keycloakify/tools/Object.fromEntries";
|
||||
import { assert } from "tsafe/assert";
|
||||
import messages_defaultSet_fallbackLanguage from "./messages_defaultSet/en";
|
||||
import { fetchMessages_defaultSet } from "./messages_defaultSet";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
|
||||
import { id } from "tsafe/id";
|
||||
|
||||
export type KcContextLike = {
|
||||
locale?: {
|
||||
currentLanguageTag: string;
|
||||
supported: { languageTag: string; url: string; label: string }[];
|
||||
};
|
||||
"x-keycloakify": {
|
||||
messages: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
assert<KcContext extends KcContextLike ? true : false>();
|
||||
|
||||
export type GenericI18n_noJsx<MessageKey extends string> = {
|
||||
/**
|
||||
* e.g: "en", "fr", "zh-CN"
|
||||
*
|
||||
* The current language
|
||||
*/
|
||||
currentLanguageTag: string;
|
||||
/**
|
||||
* Redirect to this url to change the language.
|
||||
* After reload currentLanguageTag === newLanguageTag
|
||||
*/
|
||||
getChangeLocaleUrl: (newLanguageTag: string) => string;
|
||||
/**
|
||||
* e.g. "en" => "English", "fr" => "Français", ...
|
||||
*
|
||||
* Used to render a select that enable user to switch language.
|
||||
* ex: https://user-images.githubusercontent.com/6702424/186044799-38801eec-4e89-483b-81dd-8e9233e8c0eb.png
|
||||
* */
|
||||
labelBySupportedLanguageTag: Record<string, string>;
|
||||
/**
|
||||
*
|
||||
* Examples assuming currentLanguageTag === "en"
|
||||
* {
|
||||
* en: {
|
||||
* "access-denied": "Access denied",
|
||||
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
|
||||
* "bar": "Bar {0}"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* msgStr("access-denied") === "Access denied"
|
||||
* msgStr("not-a-message-key") Throws an error
|
||||
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
|
||||
* msgStr("${bar}", "<strong>c</strong>") === "Bar <strong>XXX</strong>"
|
||||
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
|
||||
*/
|
||||
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
|
||||
/**
|
||||
* This is meant to be used when the key argument is variable, something that might have been configured by the user
|
||||
* in the Keycloak admin for example.
|
||||
*
|
||||
* Examples assuming currentLanguageTag === "en"
|
||||
* {
|
||||
* en: {
|
||||
* "access-denied": "Access denied",
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
|
||||
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
|
||||
*/
|
||||
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
|
||||
|
||||
/**
|
||||
* Initially the messages are in english (fallback language).
|
||||
* The translations in the current language are being fetched dynamically.
|
||||
* This property is true while the translations are being fetched.
|
||||
*/
|
||||
isFetchingTranslations: boolean;
|
||||
};
|
||||
|
||||
export type MessageKey_defaultSet = keyof typeof messages_defaultSet_fallbackLanguage;
|
||||
|
||||
export function createGetI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag_themeDefined: {
|
||||
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
|
||||
}) {
|
||||
type I18n = GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>;
|
||||
|
||||
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
|
||||
|
||||
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
|
||||
|
||||
function getI18n(params: { kcContext: KcContextLike }): Result {
|
||||
const { kcContext } = params;
|
||||
|
||||
use_cache: {
|
||||
const cachedResult = cachedResultByKcContext.get(kcContext);
|
||||
|
||||
if (cachedResult === undefined) {
|
||||
break use_cache;
|
||||
}
|
||||
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const partialI18n: Pick<I18n, "currentLanguageTag" | "getChangeLocaleUrl" | "labelBySupportedLanguageTag"> = {
|
||||
currentLanguageTag: kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG,
|
||||
getChangeLocaleUrl: newLanguageTag => {
|
||||
const { locale } = kcContext;
|
||||
|
||||
assert(locale !== undefined, "Internationalization not enabled");
|
||||
|
||||
const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag);
|
||||
|
||||
assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`);
|
||||
|
||||
return targetSupportedLocale.url;
|
||||
},
|
||||
labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]))
|
||||
};
|
||||
|
||||
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
|
||||
messages_themeDefined:
|
||||
messagesByLanguageTag_themeDefined[partialI18n.currentLanguageTag] ??
|
||||
messagesByLanguageTag_themeDefined[FALLBACK_LANGUAGE_TAG] ??
|
||||
(() => {
|
||||
const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0];
|
||||
if (firstLanguageTag === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return messagesByLanguageTag_themeDefined[firstLanguageTag];
|
||||
})(),
|
||||
messages_fromKcServer: kcContext["x-keycloakify"].messages
|
||||
});
|
||||
|
||||
const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag === FALLBACK_LANGUAGE_TAG;
|
||||
|
||||
const result: Result = {
|
||||
i18n: {
|
||||
...partialI18n,
|
||||
...createI18nTranslationFunctions({
|
||||
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
|
||||
}),
|
||||
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
|
||||
},
|
||||
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
|
||||
? undefined
|
||||
: (async () => {
|
||||
const messages_defaultSet_currentLanguage = await fetchMessages_defaultSet(partialI18n.currentLanguageTag);
|
||||
|
||||
const i18n_currentLanguage: I18n = {
|
||||
...partialI18n,
|
||||
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
|
||||
isFetchingTranslations: false
|
||||
};
|
||||
|
||||
// NOTE: This promise.resolve is just because without it we TypeScript
|
||||
// gives a Variable 'result' is used before being assigned. error
|
||||
await Promise.resolve().then(() => {
|
||||
result.i18n = i18n_currentLanguage;
|
||||
result.prI18n_currentLanguage = undefined;
|
||||
});
|
||||
|
||||
return i18n_currentLanguage;
|
||||
})()
|
||||
};
|
||||
|
||||
cachedResultByKcContext.set(kcContext, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return { getI18n };
|
||||
}
|
||||
|
||||
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
|
||||
messages_themeDefined: Record<MessageKey_themeDefined, string> | undefined;
|
||||
messages_fromKcServer: Record<string, string>;
|
||||
}) {
|
||||
const { messages_themeDefined, messages_fromKcServer } = params;
|
||||
|
||||
function createI18nTranslationFunctions(params: {
|
||||
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
|
||||
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined>, "msgStr" | "advancedMsgStr"> {
|
||||
const { messages_defaultSet_currentLanguage } = params;
|
||||
|
||||
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
|
||||
const { key, args } = props;
|
||||
|
||||
const message =
|
||||
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
|
||||
id<Record<string, string | undefined> | undefined>(messages_themeDefined)?.[key] ??
|
||||
id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
|
||||
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];
|
||||
|
||||
if (message === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startIndex = message
|
||||
.match(/{[0-9]+}/g)
|
||||
?.map(g => g.match(/{([0-9]+)}/)![1])
|
||||
.map(indexStr => parseInt(indexStr))
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
if (startIndex === undefined) {
|
||||
// No {0} in message (no arguments expected)
|
||||
return message;
|
||||
}
|
||||
|
||||
let messageWithArgsInjected = message;
|
||||
|
||||
args.forEach((arg, i) => {
|
||||
if (arg === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
messageWithArgsInjected = messageWithArgsInjected.replace(
|
||||
new RegExp(`\\{${i + startIndex}\\}`, "g"),
|
||||
(() => {
|
||||
if (key === "loginTitleHtml") {
|
||||
return arg;
|
||||
}
|
||||
|
||||
return arg.replace(/</g, "<").replace(/>/g, ">");
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
return messageWithArgsInjected;
|
||||
}
|
||||
|
||||
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
|
||||
const { key, args } = props;
|
||||
|
||||
const match = key.match(/^\$\{(.+)\}$/);
|
||||
|
||||
return resolveMsg({ key: match !== null ? match[1] : key, args }) ?? key;
|
||||
}
|
||||
|
||||
return {
|
||||
msgStr: (key, ...args) => {
|
||||
const resolvedMessage = resolveMsg({ key, args });
|
||||
assert(resolvedMessage !== undefined, `Message with key "${key}" not found`);
|
||||
return resolvedMessage;
|
||||
},
|
||||
advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args })
|
||||
};
|
||||
}
|
||||
|
||||
return { createI18nTranslationFunctions };
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
export * from "./withJsx";
|
||||
import type { GenericI18n } from "./withJsx/GenericI18n";
|
||||
import type { MessageKey as MessageKey_defaultSet } from "./messages_defaultSet/types";
|
||||
/** INTERNAL: DO NOT IMPORT THIS */
|
||||
export type I18n = GenericI18n<MessageKey_defaultSet, string>;
|
||||
import type { GenericI18n } from "./GenericI18n";
|
||||
import type { MessageKey_defaultSet, KcContextLike } from "./i18n";
|
||||
export type { MessageKey_defaultSet, KcContextLike };
|
||||
export type I18n = GenericI18n<MessageKey_defaultSet>;
|
||||
export { createUseI18n } from "./useI18n";
|
||||
|
@ -1,64 +0,0 @@
|
||||
export type GenericI18n_noJsx<MessageKey extends string, LanguageTag extends string> = {
|
||||
currentLanguage: {
|
||||
/**
|
||||
* e.g: "en", "fr", "zh-CN"
|
||||
*
|
||||
* The current language
|
||||
*/
|
||||
languageTag: LanguageTag;
|
||||
/**
|
||||
* e.g: "English", "Français", "中文(简体)"
|
||||
*
|
||||
* The current language
|
||||
*/
|
||||
label: string;
|
||||
};
|
||||
/**
|
||||
* Array of languages enabled on the realm.
|
||||
*/
|
||||
enabledLanguages: {
|
||||
languageTag: LanguageTag;
|
||||
label: string;
|
||||
href: string;
|
||||
}[];
|
||||
/**
|
||||
*
|
||||
* Examples assuming currentLanguageTag === "en"
|
||||
* {
|
||||
* en: {
|
||||
* "access-denied": "Access denied",
|
||||
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
|
||||
* "bar": "Bar {0}"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* msgStr("access-denied") === "Access denied"
|
||||
* msgStr("not-a-message-key") Throws an error
|
||||
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
|
||||
* msgStr("${bar}", "<strong>c</strong>") === "Bar <strong>XXX</strong>"
|
||||
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
|
||||
*/
|
||||
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
|
||||
/**
|
||||
* This is meant to be used when the key argument is variable, something that might have been configured by the user
|
||||
* in the Keycloak admin for example.
|
||||
*
|
||||
* Examples assuming currentLanguageTag === "en"
|
||||
* {
|
||||
* en: {
|
||||
* "access-denied": "Access denied",
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
|
||||
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
|
||||
*/
|
||||
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
|
||||
|
||||
/**
|
||||
* Initially the messages are in english (fallback language).
|
||||
* The translations in the current language are being fetched dynamically.
|
||||
* This property is true while the translations are being fetched.
|
||||
*/
|
||||
isFetchingTranslations: boolean;
|
||||
};
|
@ -1,341 +0,0 @@
|
||||
import "keycloakify/tools/Object.fromEntries";
|
||||
import { assert } from "tsafe/assert";
|
||||
import messages_defaultSet_fallbackLanguage from "../messages_defaultSet/en";
|
||||
import { fetchMessages_defaultSet } from "../messages_defaultSet";
|
||||
import type { KcContext } from "../../KcContext";
|
||||
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
|
||||
import { id } from "tsafe/id";
|
||||
import { is } from "tsafe/is";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
import {
|
||||
type LanguageTag as LanguageTag_defaultSet,
|
||||
type MessageKey as MessageKey_defaultSet,
|
||||
languageTags as languageTags_defaultSet
|
||||
} from "../messages_defaultSet/types";
|
||||
import type { GenericI18n_noJsx } from "./GenericI18n_noJsx";
|
||||
|
||||
export type KcContextLike = {
|
||||
themeName: string;
|
||||
locale?: {
|
||||
currentLanguageTag: string;
|
||||
supported: { languageTag: string; url: string; label: string }[];
|
||||
};
|
||||
"x-keycloakify": {
|
||||
messages: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
assert<KcContext extends KcContextLike ? true : false>();
|
||||
|
||||
export type ReturnTypeOfCreateGetI18n<MessageKey_themeDefined extends string, LanguageTag_notInDefaultSet extends string> = {
|
||||
getI18n: (params: { kcContext: KcContextLike }) => {
|
||||
i18n: GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
|
||||
prI18n_currentLanguage:
|
||||
| Promise<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>>
|
||||
| undefined;
|
||||
};
|
||||
ofTypeI18n: GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
|
||||
};
|
||||
|
||||
export function createGetI18n<
|
||||
ThemeName extends string = string,
|
||||
MessageKey_themeDefined extends string = never,
|
||||
LanguageTag_notInDefaultSet extends string = never
|
||||
>(params: {
|
||||
extraLanguageTranslations: {
|
||||
[languageTag in LanguageTag_notInDefaultSet]: {
|
||||
label: string;
|
||||
getMessages: () => Promise<{ default: Record<MessageKey_defaultSet, string> }>;
|
||||
};
|
||||
};
|
||||
messagesByLanguageTag_themeDefined: Partial<{
|
||||
[languageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: {
|
||||
[key in MessageKey_themeDefined]: string | Record<ThemeName, string>;
|
||||
};
|
||||
}>;
|
||||
}): ReturnTypeOfCreateGetI18n<MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
|
||||
const { extraLanguageTranslations, messagesByLanguageTag_themeDefined } = params;
|
||||
|
||||
Object.keys(extraLanguageTranslations).forEach(languageTag_notInDefaultSet => {
|
||||
if (id<readonly string[]>(languageTags_defaultSet).includes(languageTag_notInDefaultSet)) {
|
||||
throw new Error(
|
||||
[
|
||||
`Language "${languageTag_notInDefaultSet}" is already in the default set, you don't need to provide your own base translations for it`,
|
||||
`If you want to override some translations for this language, you can use the "withCustomTranslations" method`
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet;
|
||||
|
||||
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
|
||||
|
||||
type I18n = GenericI18n_noJsx<MessageKey, LanguageTag>;
|
||||
|
||||
type Result = { i18n: I18n; prI18n_currentLanguage: Promise<I18n> | undefined };
|
||||
|
||||
const cachedResultByKcContext = new WeakMap<KcContextLike, Result>();
|
||||
|
||||
function getI18n(params: { kcContext: KcContextLike }): Result {
|
||||
const { kcContext } = params;
|
||||
|
||||
use_cache: {
|
||||
const cachedResult = cachedResultByKcContext.get(kcContext);
|
||||
|
||||
if (cachedResult === undefined) {
|
||||
break use_cache;
|
||||
}
|
||||
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
{
|
||||
const currentLanguageTag = kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG;
|
||||
const html = document.querySelector("html");
|
||||
assert(html !== null);
|
||||
html.lang = currentLanguageTag;
|
||||
}
|
||||
|
||||
const getLanguageLabel = (languageTag: LanguageTag) => {
|
||||
form_user_added_languages: {
|
||||
if (!(languageTag in extraLanguageTranslations)) {
|
||||
break form_user_added_languages;
|
||||
}
|
||||
assert(is<Exclude<LanguageTag, LanguageTag_defaultSet>>(languageTag));
|
||||
|
||||
const entry = extraLanguageTranslations[languageTag];
|
||||
|
||||
return entry.label;
|
||||
}
|
||||
|
||||
from_server: {
|
||||
if (kcContext.locale === undefined) {
|
||||
break from_server;
|
||||
}
|
||||
|
||||
const supportedEntry = kcContext.locale.supported.find(entry => entry.languageTag === languageTag);
|
||||
|
||||
if (supportedEntry === undefined) {
|
||||
break from_server;
|
||||
}
|
||||
|
||||
// cspell: disable-next-line
|
||||
// from "Espagnol (Español)" we want to extract "Español"
|
||||
const match = supportedEntry.label.match(/[^(]+\(([^)]+)\)/);
|
||||
|
||||
if (match !== null) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
return supportedEntry.label;
|
||||
}
|
||||
|
||||
// NOTE: This should never happen
|
||||
return languageTag;
|
||||
};
|
||||
|
||||
const currentLanguage: I18n["currentLanguage"] = (() => {
|
||||
const languageTag = id<string>(kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG) as LanguageTag;
|
||||
|
||||
return {
|
||||
languageTag,
|
||||
label: getLanguageLabel(languageTag)
|
||||
};
|
||||
})();
|
||||
|
||||
const enabledLanguages: I18n["enabledLanguages"] = (() => {
|
||||
const enabledLanguages: I18n["enabledLanguages"] = [];
|
||||
|
||||
if (kcContext.locale !== undefined) {
|
||||
for (const entry of kcContext.locale.supported ?? []) {
|
||||
const languageTag = id<string>(entry.languageTag) as LanguageTag;
|
||||
|
||||
enabledLanguages.push({
|
||||
languageTag,
|
||||
label: getLanguageLabel(languageTag),
|
||||
href: entry.url
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (enabledLanguages.find(({ languageTag }) => languageTag === currentLanguage.languageTag) === undefined) {
|
||||
enabledLanguages.push({
|
||||
languageTag: currentLanguage.languageTag,
|
||||
label: getLanguageLabel(currentLanguage.languageTag),
|
||||
href: "#"
|
||||
});
|
||||
}
|
||||
|
||||
return enabledLanguages;
|
||||
})();
|
||||
|
||||
const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory<MessageKey_themeDefined>({
|
||||
themeName: kcContext.themeName,
|
||||
messages_themeDefined:
|
||||
messagesByLanguageTag_themeDefined[currentLanguage.languageTag] ??
|
||||
messagesByLanguageTag_themeDefined[id<string>(FALLBACK_LANGUAGE_TAG) as LanguageTag] ??
|
||||
(() => {
|
||||
const firstLanguageTag = Object.keys(messagesByLanguageTag_themeDefined)[0];
|
||||
if (firstLanguageTag === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return messagesByLanguageTag_themeDefined[firstLanguageTag as LanguageTag];
|
||||
})(),
|
||||
messages_fromKcServer: kcContext["x-keycloakify"].messages
|
||||
});
|
||||
|
||||
const isCurrentLanguageFallbackLanguage = currentLanguage.languageTag === FALLBACK_LANGUAGE_TAG;
|
||||
|
||||
const result: Result = {
|
||||
i18n: {
|
||||
currentLanguage,
|
||||
enabledLanguages,
|
||||
...createI18nTranslationFunctions({
|
||||
messages_defaultSet_currentLanguage: isCurrentLanguageFallbackLanguage ? messages_defaultSet_fallbackLanguage : undefined
|
||||
}),
|
||||
isFetchingTranslations: !isCurrentLanguageFallbackLanguage
|
||||
},
|
||||
prI18n_currentLanguage: isCurrentLanguageFallbackLanguage
|
||||
? undefined
|
||||
: (async () => {
|
||||
const messages_defaultSet_currentLanguage = await (async () => {
|
||||
const currentLanguageTag = currentLanguage.languageTag;
|
||||
|
||||
const fromDefaultSet = await fetchMessages_defaultSet(currentLanguageTag);
|
||||
|
||||
const isEmpty = (() => {
|
||||
for (let _key in fromDefaultSet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})();
|
||||
|
||||
if (isEmpty) {
|
||||
assert(is<Exclude<LanguageTag, LanguageTag_defaultSet>>(currentLanguageTag));
|
||||
|
||||
const entry = extraLanguageTranslations[currentLanguageTag];
|
||||
|
||||
assert(entry !== undefined);
|
||||
|
||||
return entry.getMessages().then(({ default: messages }) => messages);
|
||||
}
|
||||
|
||||
return fromDefaultSet;
|
||||
})();
|
||||
|
||||
const i18n_currentLanguage: I18n = {
|
||||
currentLanguage,
|
||||
enabledLanguages,
|
||||
...createI18nTranslationFunctions({ messages_defaultSet_currentLanguage }),
|
||||
isFetchingTranslations: false
|
||||
};
|
||||
|
||||
// NOTE: This promise.resolve is just because without it we TypeScript
|
||||
// gives a Variable 'result' is used before being assigned. error
|
||||
await Promise.resolve().then(() => {
|
||||
result.i18n = i18n_currentLanguage;
|
||||
result.prI18n_currentLanguage = undefined;
|
||||
});
|
||||
|
||||
return i18n_currentLanguage;
|
||||
})()
|
||||
};
|
||||
|
||||
cachedResultByKcContext.set(kcContext, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
getI18n,
|
||||
ofTypeI18n: Reflect<I18n>()
|
||||
};
|
||||
}
|
||||
|
||||
function createI18nTranslationFunctionsFactory<MessageKey_themeDefined extends string>(params: {
|
||||
themeName: string;
|
||||
messages_themeDefined: Record<MessageKey_themeDefined, string | Record<string, string>> | undefined;
|
||||
messages_fromKcServer: Record<string, string>;
|
||||
}) {
|
||||
const { themeName, messages_themeDefined, messages_fromKcServer } = params;
|
||||
|
||||
function createI18nTranslationFunctions(params: {
|
||||
messages_defaultSet_currentLanguage: Partial<Record<MessageKey_defaultSet, string>> | undefined;
|
||||
}): Pick<GenericI18n_noJsx<MessageKey_defaultSet | MessageKey_themeDefined, string>, "msgStr" | "advancedMsgStr"> {
|
||||
const { messages_defaultSet_currentLanguage } = params;
|
||||
|
||||
function resolveMsg(props: { key: string; args: (string | undefined)[] }): string | undefined {
|
||||
const { key, args } = props;
|
||||
|
||||
const message =
|
||||
id<Record<string, string | undefined>>(messages_fromKcServer)[key] ??
|
||||
(() => {
|
||||
const messageOrMap = id<Record<string, string | Record<string, string> | undefined> | undefined>(messages_themeDefined)?.[key];
|
||||
|
||||
if (messageOrMap === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof messageOrMap === "string") {
|
||||
return messageOrMap;
|
||||
}
|
||||
|
||||
const message = messageOrMap[themeName];
|
||||
|
||||
assert(message !== undefined, `No translation for theme variant "${themeName}" for key "${key}"`);
|
||||
|
||||
return message;
|
||||
})() ??
|
||||
id<Record<string, string | undefined> | undefined>(messages_defaultSet_currentLanguage)?.[key] ??
|
||||
id<Record<string, string | undefined>>(messages_defaultSet_fallbackLanguage)[key];
|
||||
|
||||
if (message === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startIndex = message
|
||||
.match(/{[0-9]+}/g)
|
||||
?.map(g => g.match(/{([0-9]+)}/)![1])
|
||||
.map(indexStr => parseInt(indexStr))
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
if (startIndex === undefined) {
|
||||
// No {0} in message (no arguments expected)
|
||||
return message;
|
||||
}
|
||||
|
||||
let messageWithArgsInjected = message;
|
||||
|
||||
args.forEach((arg, i) => {
|
||||
if (arg === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
messageWithArgsInjected = messageWithArgsInjected.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg);
|
||||
});
|
||||
|
||||
return messageWithArgsInjected;
|
||||
}
|
||||
|
||||
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[] }): string {
|
||||
const { key, args } = props;
|
||||
|
||||
const match = key.match(/^\$\{(.+)\}$/);
|
||||
|
||||
return resolveMsg({ key: match !== null ? match[1] : key, args }) ?? key;
|
||||
}
|
||||
|
||||
return {
|
||||
msgStr: (key, ...args) => {
|
||||
const resolvedMessage = resolveMsg({ key, args });
|
||||
assert(resolvedMessage !== undefined, `Message with key "${key}" not found`);
|
||||
return resolvedMessage;
|
||||
},
|
||||
advancedMsgStr: (key, ...args) => resolveMsgAdvanced({ key, args })
|
||||
};
|
||||
}
|
||||
|
||||
return { createI18nTranslationFunctions };
|
||||
}
|
@ -1,117 +0,0 @@
|
||||
import type {
|
||||
LanguageTag as LanguageTag_defaultSet,
|
||||
MessageKey as MessageKey_defaultSet
|
||||
} from "../messages_defaultSet/types";
|
||||
import { type ReturnTypeOfCreateGetI18n, createGetI18n } from "./getI18n";
|
||||
|
||||
export type I18nBuilder<
|
||||
ThemeName extends string = never,
|
||||
MessageKey_themeDefined extends string = never,
|
||||
LanguageTag_notInDefaultSet extends string = never,
|
||||
ExcludedMethod extends
|
||||
| "withThemeName"
|
||||
| "withExtraLanguages"
|
||||
| "withCustomTranslations" = never
|
||||
> = Omit<
|
||||
{
|
||||
withThemeName: <ThemeName extends string>() => I18nBuilder<
|
||||
ThemeName,
|
||||
MessageKey_themeDefined,
|
||||
LanguageTag_notInDefaultSet,
|
||||
ExcludedMethod | "withThemeName"
|
||||
>;
|
||||
withExtraLanguages: <
|
||||
LanguageTag_notInDefaultSet extends string
|
||||
>(extraLanguageTranslations: {
|
||||
[LanguageTag in LanguageTag_notInDefaultSet]: {
|
||||
label: string;
|
||||
getMessages: () => Promise<{
|
||||
default: Record<MessageKey_defaultSet, string>;
|
||||
}>;
|
||||
};
|
||||
}) => I18nBuilder<
|
||||
ThemeName,
|
||||
MessageKey_themeDefined,
|
||||
LanguageTag_notInDefaultSet,
|
||||
ExcludedMethod | "withExtraLanguages"
|
||||
>;
|
||||
withCustomTranslations: <MessageKey_themeDefined extends string>(
|
||||
messagesByLanguageTag_themeDefined: Partial<{
|
||||
[LanguageTag in
|
||||
| LanguageTag_defaultSet
|
||||
| LanguageTag_notInDefaultSet]: Record<
|
||||
MessageKey_themeDefined,
|
||||
string | Record<ThemeName, string>
|
||||
>;
|
||||
}>
|
||||
) => I18nBuilder<
|
||||
ThemeName,
|
||||
MessageKey_themeDefined,
|
||||
LanguageTag_notInDefaultSet,
|
||||
ExcludedMethod | "withCustomTranslations"
|
||||
>;
|
||||
build: () => ReturnTypeOfCreateGetI18n<
|
||||
MessageKey_themeDefined,
|
||||
LanguageTag_notInDefaultSet
|
||||
>;
|
||||
},
|
||||
ExcludedMethod
|
||||
>;
|
||||
|
||||
function createI18nBuilder<
|
||||
ThemeName extends string = never,
|
||||
MessageKey_themeDefined extends string = never,
|
||||
LanguageTag_notInDefaultSet extends string = never
|
||||
>(params: {
|
||||
extraLanguageTranslations: {
|
||||
[LanguageTag in LanguageTag_notInDefaultSet]: {
|
||||
label: string;
|
||||
getMessages: () => Promise<{
|
||||
default: Record<MessageKey_defaultSet, string>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
messagesByLanguageTag_themeDefined: Partial<{
|
||||
[LanguageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: Record<
|
||||
MessageKey_themeDefined,
|
||||
string | Record<ThemeName, string>
|
||||
>;
|
||||
}>;
|
||||
}): I18nBuilder<ThemeName, MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
|
||||
const i18nBuilder: I18nBuilder<
|
||||
ThemeName,
|
||||
MessageKey_themeDefined,
|
||||
LanguageTag_notInDefaultSet
|
||||
> = {
|
||||
withThemeName: () =>
|
||||
createI18nBuilder({
|
||||
extraLanguageTranslations: params.extraLanguageTranslations,
|
||||
messagesByLanguageTag_themeDefined:
|
||||
params.messagesByLanguageTag_themeDefined as any
|
||||
}),
|
||||
withExtraLanguages: extraLanguageTranslations =>
|
||||
createI18nBuilder({
|
||||
extraLanguageTranslations,
|
||||
messagesByLanguageTag_themeDefined:
|
||||
params.messagesByLanguageTag_themeDefined as any
|
||||
}),
|
||||
withCustomTranslations: messagesByLanguageTag_themeDefined =>
|
||||
createI18nBuilder({
|
||||
extraLanguageTranslations: params.extraLanguageTranslations,
|
||||
messagesByLanguageTag_themeDefined
|
||||
}),
|
||||
build: () =>
|
||||
createGetI18n({
|
||||
extraLanguageTranslations: params.extraLanguageTranslations,
|
||||
messagesByLanguageTag_themeDefined:
|
||||
params.messagesByLanguageTag_themeDefined
|
||||
})
|
||||
};
|
||||
|
||||
return i18nBuilder;
|
||||
}
|
||||
|
||||
export const i18nBuilder = createI18nBuilder({
|
||||
extraLanguageTranslations: {},
|
||||
messagesByLanguageTag_themeDefined: {}
|
||||
});
|
@ -1,3 +0,0 @@
|
||||
export { i18nBuilder } from "./i18nBuilder";
|
||||
export type { KcContextLike } from "./getI18n";
|
||||
export type { MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";
|
95
src/login/i18n/useI18n.tsx
Normal file
95
src/login/i18n/useI18n.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n";
|
||||
import { GenericI18n } from "./GenericI18n";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
|
||||
export function createUseI18n<MessageKey_themeDefined extends string = never>(messagesByLanguageTag: {
|
||||
[languageTag: string]: { [key in MessageKey_themeDefined]: string };
|
||||
}) {
|
||||
type MessageKey = MessageKey_defaultSet | MessageKey_themeDefined;
|
||||
|
||||
type I18n = GenericI18n<MessageKey>;
|
||||
|
||||
const { withJsx } = (() => {
|
||||
const cache = new WeakMap<GenericI18n_noJsx<MessageKey>, GenericI18n<MessageKey>>();
|
||||
|
||||
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
|
||||
const { htmlString, msgKey } = params;
|
||||
return (
|
||||
<div
|
||||
data-kc-msg={msgKey}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: htmlString
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey>): I18n {
|
||||
use_cache: {
|
||||
const i18n = cache.get(i18n_noJsx);
|
||||
|
||||
if (i18n === undefined) {
|
||||
break use_cache;
|
||||
}
|
||||
|
||||
return i18n;
|
||||
}
|
||||
|
||||
const i18n: I18n = {
|
||||
...i18n_noJsx,
|
||||
msg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.msgStr(msgKey, ...args), msgKey }),
|
||||
advancedMsg: (msgKey, ...args) => renderHtmlString({ htmlString: i18n_noJsx.advancedMsgStr(msgKey, ...args), msgKey })
|
||||
};
|
||||
|
||||
cache.set(i18n_noJsx, i18n);
|
||||
|
||||
return i18n;
|
||||
}
|
||||
|
||||
return { withJsx };
|
||||
})();
|
||||
|
||||
add_style: {
|
||||
const attributeName = "data-kc-i18n";
|
||||
|
||||
// Check if already exists in head
|
||||
if (document.querySelector(`style[${attributeName}]`) !== null) {
|
||||
break add_style;
|
||||
}
|
||||
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.attributes.setNamedItem(document.createAttribute(attributeName));
|
||||
(styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement);
|
||||
}
|
||||
|
||||
const { getI18n } = createGetI18n(messagesByLanguageTag);
|
||||
|
||||
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
|
||||
const { kcContext } = params;
|
||||
|
||||
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });
|
||||
|
||||
const [i18n_toReturn, setI18n_toReturn] = useState<I18n>(withJsx(i18n));
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
prI18n_currentLanguage?.then(i18n => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setI18n_toReturn(withJsx(i18n));
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { i18n: i18n_toReturn };
|
||||
}
|
||||
|
||||
return { useI18n, ofTypeI18n: Reflect<I18n>() };
|
||||
}
|
@ -1,81 +0,0 @@
|
||||
import type { GenericI18n_noJsx } from "../noJsx/GenericI18n_noJsx";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
|
||||
export type GenericI18n<MessageKey extends string, LanguageTag extends string> = {
|
||||
currentLanguage: {
|
||||
/**
|
||||
* e.g: "en", "fr", "zh-CN"
|
||||
*
|
||||
* The current language
|
||||
*/
|
||||
languageTag: LanguageTag;
|
||||
/**
|
||||
* e.g: "English", "Français", "中文(简体)"
|
||||
*
|
||||
* The current language
|
||||
*/
|
||||
label: string;
|
||||
};
|
||||
/**
|
||||
* Array of languages enabled on the realm.
|
||||
*/
|
||||
enabledLanguages: {
|
||||
languageTag: LanguageTag;
|
||||
label: string;
|
||||
href: string;
|
||||
}[];
|
||||
/**
|
||||
*
|
||||
* Examples assuming currentLanguageTag === "en"
|
||||
* {
|
||||
* en: {
|
||||
* "access-denied": "Access denied",
|
||||
* "impersonateTitleHtml": "<strong>{0}</strong> Impersonate User",
|
||||
* "bar": "Bar {0}"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* msgStr("access-denied") === "Access denied"
|
||||
* msgStr("not-a-message-key") Throws an error
|
||||
* msgStr("impersonateTitleHtml", "Foo") === "<strong>Foo</strong> Impersonate User"
|
||||
* msgStr("${bar}", "<strong>c</strong>") === "Bar <strong>XXX</strong>"
|
||||
* The html in the arg is partially escaped for security reasons, it might come from an untrusted source, it's not safe to render it as html.
|
||||
*/
|
||||
msgStr: (key: MessageKey, ...args: (string | undefined)[]) => string;
|
||||
/**
|
||||
* This is meant to be used when the key argument is variable, something that might have been configured by the user
|
||||
* in the Keycloak admin for example.
|
||||
*
|
||||
* Examples assuming currentLanguageTag === "en"
|
||||
* {
|
||||
* en: {
|
||||
* "access-denied": "Access denied",
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* advancedMsgStr("${access-denied}") === advancedMsgStr("access-denied") === msgStr("access-denied") === "Access denied"
|
||||
* advancedMsgStr("${not-a-message-key}") === advancedMsgStr("not-a-message-key") === "not-a-message-key"
|
||||
*/
|
||||
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
|
||||
/**
|
||||
* Initially the messages are in english (fallback language).
|
||||
* The translations in the current language are being fetched dynamically.
|
||||
* This property is true while the translations are being fetched.
|
||||
*/
|
||||
isFetchingTranslations: boolean;
|
||||
/**
|
||||
* Same as msgStr but returns a JSX.Element with the html string rendered as html.
|
||||
*/
|
||||
msg: (key: MessageKey, ...args: (string | undefined)[]) => JSX.Element;
|
||||
/**
|
||||
* Same as advancedMsgStr but returns a JSX.Element with the html string rendered as html.
|
||||
*/
|
||||
advancedMsg: (key: string, ...args: (string | undefined)[]) => JSX.Element;
|
||||
};
|
||||
|
||||
{
|
||||
type A = Omit<GenericI18n<string, string>, "msg" | "advancedMsg">;
|
||||
type B = GenericI18n_noJsx<string, string>;
|
||||
|
||||
assert<Equals<A, B>>;
|
||||
}
|
@ -1,117 +0,0 @@
|
||||
import type {
|
||||
LanguageTag as LanguageTag_defaultSet,
|
||||
MessageKey as MessageKey_defaultSet
|
||||
} from "../messages_defaultSet/types";
|
||||
import { type ReturnTypeOfCreateUseI18n, createUseI18n } from "../withJsx/useI18n";
|
||||
|
||||
export type I18nBuilder<
|
||||
ThemeName extends string = never,
|
||||
MessageKey_themeDefined extends string = never,
|
||||
LanguageTag_notInDefaultSet extends string = never,
|
||||
ExcludedMethod extends
|
||||
| "withThemeName"
|
||||
| "withExtraLanguages"
|
||||
| "withCustomTranslations" = never
|
||||
> = Omit<
|
||||
{
|
||||
withThemeName: <ThemeName extends string>() => I18nBuilder<
|
||||
ThemeName,
|
||||
MessageKey_themeDefined,
|
||||
LanguageTag_notInDefaultSet,
|
||||
ExcludedMethod | "withThemeName"
|
||||
>;
|
||||
withExtraLanguages: <
|
||||
LanguageTag_notInDefaultSet extends string
|
||||
>(extraLanguageTranslations: {
|
||||
[LanguageTag in LanguageTag_notInDefaultSet]: {
|
||||
label: string;
|
||||
getMessages: () => Promise<{
|
||||
default: Record<MessageKey_defaultSet, string>;
|
||||
}>;
|
||||
};
|
||||
}) => I18nBuilder<
|
||||
ThemeName,
|
||||
MessageKey_themeDefined,
|
||||
LanguageTag_notInDefaultSet,
|
||||
ExcludedMethod | "withExtraLanguages"
|
||||
>;
|
||||
withCustomTranslations: <MessageKey_themeDefined extends string>(
|
||||
messagesByLanguageTag_themeDefined: Partial<{
|
||||
[LanguageTag in
|
||||
| LanguageTag_defaultSet
|
||||
| LanguageTag_notInDefaultSet]: Record<
|
||||
MessageKey_themeDefined,
|
||||
string | Record<ThemeName, string>
|
||||
>;
|
||||
}>
|
||||
) => I18nBuilder<
|
||||
ThemeName,
|
||||
MessageKey_themeDefined,
|
||||
LanguageTag_notInDefaultSet,
|
||||
ExcludedMethod | "withCustomTranslations"
|
||||
>;
|
||||
build: () => ReturnTypeOfCreateUseI18n<
|
||||
MessageKey_themeDefined,
|
||||
LanguageTag_notInDefaultSet
|
||||
>;
|
||||
},
|
||||
ExcludedMethod
|
||||
>;
|
||||
|
||||
function createI18nBuilder<
|
||||
ThemeName extends string = never,
|
||||
MessageKey_themeDefined extends string = never,
|
||||
LanguageTag_notInDefaultSet extends string = never
|
||||
>(params: {
|
||||
extraLanguageTranslations: {
|
||||
[LanguageTag in LanguageTag_notInDefaultSet]: {
|
||||
label: string;
|
||||
getMessages: () => Promise<{
|
||||
default: Record<MessageKey_defaultSet, string>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
messagesByLanguageTag_themeDefined: Partial<{
|
||||
[LanguageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: Record<
|
||||
MessageKey_themeDefined,
|
||||
string | Record<ThemeName, string>
|
||||
>;
|
||||
}>;
|
||||
}): I18nBuilder<ThemeName, MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
|
||||
const i18nBuilder: I18nBuilder<
|
||||
ThemeName,
|
||||
MessageKey_themeDefined,
|
||||
LanguageTag_notInDefaultSet
|
||||
> = {
|
||||
withThemeName: () =>
|
||||
createI18nBuilder({
|
||||
extraLanguageTranslations: params.extraLanguageTranslations,
|
||||
messagesByLanguageTag_themeDefined:
|
||||
params.messagesByLanguageTag_themeDefined as any
|
||||
}),
|
||||
withExtraLanguages: extraLanguageTranslations =>
|
||||
createI18nBuilder({
|
||||
extraLanguageTranslations,
|
||||
messagesByLanguageTag_themeDefined:
|
||||
params.messagesByLanguageTag_themeDefined as any
|
||||
}),
|
||||
withCustomTranslations: messagesByLanguageTag_themeDefined =>
|
||||
createI18nBuilder({
|
||||
extraLanguageTranslations: params.extraLanguageTranslations,
|
||||
messagesByLanguageTag_themeDefined
|
||||
}),
|
||||
build: () =>
|
||||
createUseI18n({
|
||||
extraLanguageTranslations: params.extraLanguageTranslations,
|
||||
messagesByLanguageTag_themeDefined:
|
||||
params.messagesByLanguageTag_themeDefined
|
||||
})
|
||||
};
|
||||
|
||||
return i18nBuilder;
|
||||
}
|
||||
|
||||
export const i18nBuilder = createI18nBuilder({
|
||||
extraLanguageTranslations: {},
|
||||
messagesByLanguageTag_themeDefined: {}
|
||||
});
|
@ -1,3 +0,0 @@
|
||||
export { i18nBuilder } from "./i18nBuilder";
|
||||
export type { KcContextLike } from "./useI18n";
|
||||
export type { MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";
|
@ -1,3 +1,3 @@
|
||||
export type { ExtendKcContext, Attribute } from "keycloakify/login/KcContext";
|
||||
export type { ClassKey } from "keycloakify/login/TemplateProps";
|
||||
export { i18nBuilder, type MessageKey_defaultSet } from "keycloakify/login/i18n";
|
||||
export { createUseI18n } from "keycloakify/login/i18n";
|
||||
|
@ -1,130 +1,5 @@
|
||||
import { createGetKcClsx } from "keycloakify/lib/getKcClsx";
|
||||
|
||||
export type ClassKey =
|
||||
| "kcBodyClass"
|
||||
| "kcHeaderWrapperClass"
|
||||
| "kcLocaleWrapperClass"
|
||||
| "kcInfoAreaWrapperClass"
|
||||
| "kcFormButtonsWrapperClass"
|
||||
| "kcFormOptionsWrapperClass"
|
||||
| "kcCheckboxInputClass"
|
||||
| "kcLocaleDropDownClass"
|
||||
| "kcLocaleListItemClass"
|
||||
| "kcContentWrapperClass"
|
||||
| "kcLogoIdP-facebook"
|
||||
| "kcAuthenticatorOTPClass"
|
||||
| "kcLogoIdP-bitbucket"
|
||||
| "kcAuthenticatorWebAuthnClass"
|
||||
| "kcWebAuthnDefaultIcon"
|
||||
| "kcLogoIdP-stackoverflow"
|
||||
| "kcSelectAuthListItemClass"
|
||||
| "kcLogoIdP-microsoft"
|
||||
| "kcLoginOTPListItemHeaderClass"
|
||||
| "kcLocaleItemClass"
|
||||
| "kcLoginOTPListItemIconBodyClass"
|
||||
| "kcInputHelperTextAfterClass"
|
||||
| "kcFormClass"
|
||||
| "kcSelectAuthListClass"
|
||||
| "kcInputClassRadioCheckboxLabelDisabled"
|
||||
| "kcSelectAuthListItemIconClass"
|
||||
| "kcRecoveryCodesWarning"
|
||||
| "kcFormSettingClass"
|
||||
| "kcWebAuthnBLE"
|
||||
| "kcInputWrapperClass"
|
||||
| "kcSelectAuthListItemArrowIconClass"
|
||||
| "kcFeedbackAreaClass"
|
||||
| "kcFormPasswordVisibilityButtonClass"
|
||||
| "kcLogoIdP-google"
|
||||
| "kcCheckLabelClass"
|
||||
| "kcSelectAuthListItemFillClass"
|
||||
| "kcAuthenticatorDefaultClass"
|
||||
| "kcLogoIdP-gitlab"
|
||||
| "kcFormAreaClass"
|
||||
| "kcFormButtonsClass"
|
||||
| "kcInputClassRadioLabel"
|
||||
| "kcAuthenticatorWebAuthnPasswordlessClass"
|
||||
| "kcSelectAuthListItemHeadingClass"
|
||||
| "kcInfoAreaClass"
|
||||
| "kcLogoLink"
|
||||
| "kcContainerClass"
|
||||
| "kcSelectAuthListItemTitle"
|
||||
| "kcHtmlClass"
|
||||
| "kcLoginOTPListItemTitleClass"
|
||||
| "kcLogoIdP-openshift-v4"
|
||||
| "kcWebAuthnUnknownIcon"
|
||||
| "kcFormSocialAccountNameClass"
|
||||
| "kcLogoIdP-openshift-v3"
|
||||
| "kcLoginOTPListInputClass"
|
||||
| "kcWebAuthnUSB"
|
||||
| "kcInputClassRadio"
|
||||
| "kcWebAuthnKeyIcon"
|
||||
| "kcFeedbackInfoIcon"
|
||||
| "kcCommonLogoIdP"
|
||||
| "kcRecoveryCodesActions"
|
||||
| "kcFormGroupHeader"
|
||||
| "kcFormSocialAccountSectionClass"
|
||||
| "kcLogoIdP-instagram"
|
||||
| "kcAlertClass"
|
||||
| "kcHeaderClass"
|
||||
| "kcLabelWrapperClass"
|
||||
| "kcFormPasswordVisibilityIconShow"
|
||||
| "kcFormSocialAccountLinkClass"
|
||||
| "kcLocaleMainClass"
|
||||
| "kcInputGroup"
|
||||
| "kcTextareaClass"
|
||||
| "kcButtonBlockClass"
|
||||
| "kcButtonClass"
|
||||
| "kcWebAuthnNFC"
|
||||
| "kcLocaleClass"
|
||||
| "kcInputClassCheckboxInput"
|
||||
| "kcFeedbackErrorIcon"
|
||||
| "kcInputLargeClass"
|
||||
| "kcInputErrorMessageClass"
|
||||
| "kcRecoveryCodesList"
|
||||
| "kcFormSocialAccountListClass"
|
||||
| "kcAlertTitleClass"
|
||||
| "kcAuthenticatorPasswordClass"
|
||||
| "kcCheckInputClass"
|
||||
| "kcLogoIdP-linkedin"
|
||||
| "kcLogoIdP-twitter"
|
||||
| "kcFeedbackWarningIcon"
|
||||
| "kcResetFlowIcon"
|
||||
| "kcSelectAuthListItemIconPropertyClass"
|
||||
| "kcFeedbackSuccessIcon"
|
||||
| "kcLoginOTPListClass"
|
||||
| "kcSrOnlyClass"
|
||||
| "kcFormSocialAccountListGridClass"
|
||||
| "kcButtonDefaultClass"
|
||||
| "kcFormGroupErrorClass"
|
||||
| "kcSelectAuthListItemDescriptionClass"
|
||||
| "kcSelectAuthListItemBodyClass"
|
||||
| "kcWebAuthnInternal"
|
||||
| "kcSelectAuthListItemArrowClass"
|
||||
| "kcCheckClass"
|
||||
| "kcContentClass"
|
||||
| "kcLogoClass"
|
||||
| "kcLoginOTPListItemIconClass"
|
||||
| "kcLoginClass"
|
||||
| "kcSignUpClass"
|
||||
| "kcButtonLargeClass"
|
||||
| "kcFormCardClass"
|
||||
| "kcLocaleListClass"
|
||||
| "kcInputClass"
|
||||
| "kcFormGroupClass"
|
||||
| "kcLogoIdP-paypal"
|
||||
| "kcInputClassCheckbox"
|
||||
| "kcRecoveryCodesConfirmation"
|
||||
| "kcFormPasswordVisibilityIconHide"
|
||||
| "kcInputClassRadioInput"
|
||||
| "kcFormSocialAccountListButtonClass"
|
||||
| "kcInputClassCheckboxLabel"
|
||||
| "kcFormOptionsClass"
|
||||
| "kcFormHeaderClass"
|
||||
| "kcFormSocialAccountGridItem"
|
||||
| "kcButtonPrimaryClass"
|
||||
| "kcInputHelperTextBeforeClass"
|
||||
| "kcLogoIdP-github"
|
||||
| "kcLabelClass";
|
||||
import type { ClassKey } from "keycloakify/login/TemplateProps";
|
||||
|
||||
export const { getKcClsx } = createGetKcClsx<ClassKey>({
|
||||
defaultClasses: {
|
||||
@ -263,4 +138,6 @@ export const { getKcClsx } = createGetKcClsx<ClassKey>({
|
||||
}
|
||||
});
|
||||
|
||||
export type { ClassKey };
|
||||
|
||||
export type KcClsx = ReturnType<typeof getKcClsx>["kcClsx"];
|
||||
|
@ -3,7 +3,6 @@ import { useMemo, useReducer, useEffect, Fragment, type Dispatch } from "react";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import { id } from "tsafe/id";
|
||||
import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import { useConstCallback } from "keycloakify/tools/useConstCallback";
|
||||
import { emailRegexp } from "keycloakify/tools/emailRegExp";
|
||||
import { formatNumber } from "keycloakify/tools/formatNumber";
|
||||
@ -11,7 +10,7 @@ import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
|
||||
import type { PasswordPolicies, Attribute, Validators } from "keycloakify/login/KcContext";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { MessageKey_defaultSet } from "keycloakify/login/i18n";
|
||||
import type { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n";
|
||||
import { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export type FormFieldError = {
|
||||
@ -662,7 +661,7 @@ function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18
|
||||
<span
|
||||
key={0}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(errorMessageStr)
|
||||
__html: errorMessageStr
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
@ -1,5 +1,4 @@
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
@ -20,7 +19,7 @@ export default function Error(props: PageProps<Extract<KcContext, { pageId: "err
|
||||
headerNode={msg("errorTitle")}
|
||||
>
|
||||
<div id="kc-error-message">
|
||||
<p className="instruction" dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />
|
||||
<p className="instruction" dangerouslySetInnerHTML={{ __html: message.summary }} />
|
||||
{!skipLink && client !== undefined && client.baseUrl !== undefined && (
|
||||
<p>
|
||||
<a id="backToApplication" href={client.baseUrl}>
|
||||
|
@ -1,5 +1,4 @@
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
@ -20,7 +19,7 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
|
||||
headerNode={
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messageHeader ?? message.summary)
|
||||
__html: messageHeader ?? message.summary
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@ -29,21 +28,19 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
|
||||
<p
|
||||
className="instruction"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(
|
||||
(() => {
|
||||
let html = message.summary;
|
||||
__html: (() => {
|
||||
let html = message.summary;
|
||||
|
||||
if (requiredActions) {
|
||||
html += "<b>";
|
||||
if (requiredActions) {
|
||||
html += "<b>";
|
||||
|
||||
html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(", ");
|
||||
html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(", ");
|
||||
|
||||
html += "</b>";
|
||||
}
|
||||
html += "</b>";
|
||||
}
|
||||
|
||||
return html;
|
||||
})()
|
||||
)
|
||||
return html;
|
||||
})()
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useReducer } from "react";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
@ -63,7 +62,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
|
||||
{p.iconClasses && <i className={clsx(kcClsx("kcCommonLogoIdP"), p.iconClasses)} aria-hidden="true"></i>}
|
||||
<span
|
||||
className={clsx(kcClsx("kcFormSocialAccountNameClass"), p.iconClasses && "kc-social-icon-text")}
|
||||
dangerouslySetInnerHTML={{ __html: kcSanitize(p.displayName) }}
|
||||
dangerouslySetInnerHTML={{ __html: p.displayName }}
|
||||
></span>
|
||||
</a>
|
||||
</li>
|
||||
@ -112,7 +111,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.getFirstError("username", "password"))
|
||||
__html: messagesPerField.getFirstError("username", "password")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -140,7 +139,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.getFirstError("username", "password"))
|
||||
__html: messagesPerField.getFirstError("username", "password")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { getKcClsx, KcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
@ -118,7 +117,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.get("totp"))
|
||||
__html: messagesPerField.get("totp")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -149,7 +148,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.get("userLabel"))
|
||||
__html: messagesPerField.get("userLabel")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
@ -76,7 +75,7 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.get("totp"))
|
||||
__html: messagesPerField.get("totp")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useReducer } from "react";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
@ -66,7 +65,7 @@ export default function LoginPassword(props: PageProps<Extract<KcContext, { page
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.get("password"))
|
||||
__html: messagesPerField.get("password")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
@ -49,7 +48,7 @@ export default function LoginRecoveryAuthnCodeInput(props: PageProps<Extract<KcC
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.get("recoveryCodeInput"))
|
||||
__html: messagesPerField.get("recoveryCodeInput")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
import type { KcContext } from "../KcContext";
|
||||
import type { I18n } from "../i18n";
|
||||
@ -54,7 +53,7 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.get("username"))
|
||||
__html: messagesPerField.get("username")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import { assert } from "keycloakify/tools/assert";
|
||||
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||
@ -53,7 +52,7 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.get("password"))
|
||||
__html: messagesPerField.get("password")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -85,7 +84,7 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.get("password-confirm"))
|
||||
__html: messagesPerField.get("password-confirm")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
|
||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
|
||||
import { clsx } from "keycloakify/tools/clsx";
|
||||
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps";
|
||||
@ -146,7 +145,7 @@ function TermsAcceptance(props: {
|
||||
className={kcClsx("kcInputErrorMessageClass")}
|
||||
aria-live="polite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: kcSanitize(messagesPerField.get("termsAccepted"))
|
||||
__html: messagesPerField.get("termsAccepted")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
3
src/tools/vendor/dompurify.ts
vendored
3
src/tools/vendor/dompurify.ts
vendored
@ -1,3 +0,0 @@
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export { DOMPurify };
|
@ -166,7 +166,7 @@ export function keycloakify(params: keycloakify.Params) {
|
||||
[
|
||||
`(`,
|
||||
`(window.kcContext === undefined || import.meta.env.MODE === "development")?`,
|
||||
`import.meta.env.BASE_URL:`,
|
||||
`"${urlPathname ?? "/"}":`,
|
||||
`(window.kcContext["x-keycloakify"].resourcesPath + "/${WELL_KNOWN_DIRECTORY_BASE_NAME.DIST}/")`,
|
||||
`)`
|
||||
].join("")
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { i18nBuilder } from "../../dist/account";
|
||||
import { createUseI18n } from "../../dist/account";
|
||||
|
||||
const { useI18n, ofTypeI18n } = i18nBuilder.build();
|
||||
export const { useI18n, ofTypeI18n } = createUseI18n({});
|
||||
|
||||
type I18n = typeof ofTypeI18n;
|
||||
|
||||
export { useI18n, type I18n };
|
||||
export type I18n = typeof ofTypeI18n;
|
||||
|
@ -2,6 +2,7 @@ import React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { KeycloakifyRotatingLogo } from "./KeycloakifyRotatingLogo";
|
||||
import { useInsertLinkTags } from "../../dist/tools/useInsertLinkTags";
|
||||
import { useOnFistMount } from "../../dist/tools/useOnFirstMount";
|
||||
import { tss } from "../tss";
|
||||
|
||||
const meta = {
|
||||
|
@ -1,94 +0,0 @@
|
||||
Hello GPT,
|
||||
|
||||
So, I'm using recast in a node script to parse a typescript source file and extract the part that I'm intrested in.
|
||||
|
||||
Example of the source file:
|
||||
|
||||
```ts
|
||||
import { createUseI18n } from "keycloakify/login";
|
||||
|
||||
export const { useI18n, ofTypeI18n } = createUseI18n({
|
||||
en: {
|
||||
myCustomMessage: "My custom message"
|
||||
},
|
||||
fr: {
|
||||
myCustomMessage: "Mon message personnalisé"
|
||||
}
|
||||
});
|
||||
|
||||
export type I18n = typeof ofTypeI18n;
|
||||
```
|
||||
|
||||
The string that I want to extract from this source file is:
|
||||
|
||||
```raw
|
||||
{
|
||||
en: {
|
||||
myCustomMessage: "My custom message"
|
||||
},
|
||||
fr: {
|
||||
myCustomMessage: "Mon message personnalisé"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is my script:
|
||||
|
||||
```ts
|
||||
const root = recast.parse(fs.readFileSync(i18nTsFilePath).toString("utf8"), {
|
||||
parser: {
|
||||
parse: (code: string) =>
|
||||
babelParser.parse(code, {
|
||||
sourceType: "module",
|
||||
plugins: ["typescript"]
|
||||
}),
|
||||
generator: babelGenerate,
|
||||
types: babelTypes
|
||||
}
|
||||
});
|
||||
|
||||
let messageBundleDeclarationTsCode: string | undefined = undefined;
|
||||
|
||||
recast.visit(root, {
|
||||
visitCallExpression: function (path) {
|
||||
if (
|
||||
path.node.callee.type === "Identifier" &&
|
||||
path.node.callee.name === "createUseI18n"
|
||||
) {
|
||||
messageBundleDeclarationTsCode = babelGenerate(
|
||||
path.node.arguments[0] as any
|
||||
).code;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.traverse(path);
|
||||
}
|
||||
});
|
||||
|
||||
// Here messageBundleDeclarationTsCode contains the string I want
|
||||
```
|
||||
|
||||
It works, but now, the API has changed. The source file looks like this:
|
||||
|
||||
```ts
|
||||
import { i18nBuilder } from "keycloakify/login/i18n";
|
||||
|
||||
const { useI18n, ofTypeI18n } = i18nBuilder
|
||||
.withThemeName<"my-theme-1" | "my-theme-2">()
|
||||
.withCustomTranslations({
|
||||
en: {
|
||||
myCustomMessage: "My custom message"
|
||||
},
|
||||
fr: {
|
||||
myCustomMessage: "Mon message personnalisé"
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
type I18n = typeof ofTypeI18n;
|
||||
|
||||
export { useI18n, type I18n };
|
||||
```
|
||||
|
||||
Can you modify the script to extract the string taking into account the change that have been made to the source file?
|
||||
(I need to extract the argument that is passed to the `withCustomTranslations` method)
|
@ -1,7 +1,5 @@
|
||||
import { i18nBuilder } from "../../dist/login";
|
||||
import { createUseI18n } from "../../dist/login";
|
||||
|
||||
const { useI18n, ofTypeI18n } = i18nBuilder.build();
|
||||
export const { useI18n, ofTypeI18n } = createUseI18n({});
|
||||
|
||||
type I18n = typeof ofTypeI18n;
|
||||
|
||||
export { useI18n, type I18n };
|
||||
export type I18n = typeof ofTypeI18n;
|
||||
|
@ -115,38 +115,6 @@ export const WithFavoritePet: Story = {
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
export const WithNewsletter: Story = {
|
||||
render: () => (
|
||||
<KcPageStory
|
||||
kcContext={{
|
||||
profile: {
|
||||
attributesByName: {
|
||||
newsletter: {
|
||||
name: "newsletter",
|
||||
displayName: "Sign up to the newsletter",
|
||||
validators: {
|
||||
options: {
|
||||
options: ["yes"]
|
||||
}
|
||||
},
|
||||
annotations: {
|
||||
inputOptionLabels: {
|
||||
"yes": "I want my email inbox filled with spam"
|
||||
},
|
||||
inputType: "multiselect-checkboxes"
|
||||
},
|
||||
required: false,
|
||||
readOnly: false
|
||||
} satisfies Attribute
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
export const WithEmailAsUsername: Story = {
|
||||
render: () => (
|
||||
<KcPageStory
|
||||
|
@ -1,141 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { KcSanitizer } from "keycloakify/lib/kcSanitize/KcSanitizer";
|
||||
import { decode } from "html-entities";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
// Implementation of Keycloak Java method KeycloakSanitizerTest with bunch of more test for p tag styling
|
||||
// https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/test/java/org/keycloak/theme/KeycloakSanitizerTest.java#L32
|
||||
describe("KeycloakSanitizerMethod", () => {
|
||||
it("should handle escapes correctly", () => {
|
||||
let html: string = "";
|
||||
let expectedResult: string;
|
||||
|
||||
html =
|
||||
"<div class=\"kc-logo-text\"><script>alert('foo');</script><span>Keycloak</span></div>";
|
||||
expectedResult = '<div class="kc-logo-text"><span>Keycloak</span></div>';
|
||||
assertResult(expectedResult, html);
|
||||
|
||||
html = "<h1>Foo</h1>";
|
||||
expectedResult = "<h1>Foo</h1>";
|
||||
assertResult(expectedResult, html);
|
||||
|
||||
html =
|
||||
'<div class="kc-logo-text"><span>Keycloak</span></div><svg onload=alert(document.cookie);>';
|
||||
expectedResult = '<div class="kc-logo-text"><span>Keycloak</span></div>';
|
||||
assertResult(expectedResult, html);
|
||||
|
||||
html = "";
|
||||
expectedResult = "";
|
||||
assertResult(expectedResult, html);
|
||||
});
|
||||
|
||||
it("should handle URLs correctly", () => {
|
||||
let html: string = "";
|
||||
|
||||
html = "<p><a href='https://localhost'>link</a></p>";
|
||||
assertResult('<p><a href="https://localhost" rel="nofollow">link</a></p>', html);
|
||||
|
||||
html = '<p><a href="">link</a></p>';
|
||||
assertResult("<p>link</p>", html);
|
||||
|
||||
html = "<p><a href=\"javascript:alert('hello!');\">link</a></p>";
|
||||
assertResult("<p>link</p>", html);
|
||||
|
||||
html = '<p><a href="javascript:alert(document.domain);">link</a></p>';
|
||||
assertResult("<p>link</p>", html);
|
||||
|
||||
html = '<p><a href="javascript:alert(document.domain);">link</a></p>';
|
||||
assertResult("<p>link</p>", html);
|
||||
|
||||
html = '<p><a href="javascript&\0colon;alert(document.domain);">link</a></p>';
|
||||
assertResult("<p>link</p>", html);
|
||||
|
||||
html =
|
||||
'<p><a href="javascript&amp;\0colon;alert(document.domain);">link</a></p>';
|
||||
assertResult("<p>link</p>", html);
|
||||
|
||||
html =
|
||||
'<p><a href="javascript&amp;amp;amp;amp;amp;amp;amp;amp;amp;amp;\0colon;alert(document.domain);">link</a></p>';
|
||||
assertResult("<p>link</p>", html);
|
||||
|
||||
html = '<p><a href="https://localhost?key=123&msg=abc">link</a></p>';
|
||||
assertResult(
|
||||
'<p><a href="https://localhost?key=123&msg=abc" rel="nofollow">link</a></p>',
|
||||
html
|
||||
);
|
||||
|
||||
html =
|
||||
"<p><a href='https://localhost?key=123&msg=abc'>link1</a><a href=\"https://localhost?key=abc&msg=123\">link2</a></p>";
|
||||
assertResult(
|
||||
'<p><a href="https://localhost?key=123&msg=abc" rel="nofollow">link1</a><a href="https://localhost?key=abc&msg=123" rel="nofollow">link2</a></p>',
|
||||
html
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle ordinary texts correctly", () => {
|
||||
let html: string = "";
|
||||
|
||||
html = "Some text";
|
||||
assertResult("Some text", html);
|
||||
|
||||
html = `text with "double quotation"`;
|
||||
assertResult(`text with "double quotation"`, html);
|
||||
|
||||
html = `text with 'single quotation'`;
|
||||
assertResult(`text with 'single quotation'`, html);
|
||||
});
|
||||
|
||||
it("should handle text styles correctly", () => {
|
||||
let html: string = "";
|
||||
|
||||
html = "<p><strong>text</strong></p>";
|
||||
assertResult("<p><strong>text</strong></p>", html);
|
||||
|
||||
html = "<p><b>text</b></p>";
|
||||
assertResult("<p><b>text</b></p>", html);
|
||||
|
||||
html = `<p class="red"> red text </p>`;
|
||||
assertResult(`<p class="red"> red text </p>`, html);
|
||||
|
||||
html = `<p align="center"> <b>red text </b></p>`;
|
||||
assertResult(`<p align="center"> <b>red text </b></p>`, html);
|
||||
|
||||
html = `<p align="CenTer"> <b> Case-insensitive</b></p>`;
|
||||
assertResult(`<p align="CenTer"> <b> Case-insensitive</b></p>`, html);
|
||||
|
||||
html = `<p align="xyz"> <b>wrong value for align</b></p>`;
|
||||
assertResult(`<p> <b>wrong value for align</b></p>`, html);
|
||||
|
||||
html = `<p align="centercenter"> <b>wrong value for align</b></p>`;
|
||||
assertResult(`<p> <b>wrong value for align</b></p>`, html);
|
||||
|
||||
html = `<p style="font-size: 20px;">This is a paragraph with larger text.</p>`;
|
||||
assertResult(
|
||||
`<p style="font-size: 20px;">This is a paragraph with larger text.</p>`,
|
||||
html
|
||||
);
|
||||
|
||||
html = `<h3> או נושא שתבחר</h3>`;
|
||||
assertResult(`<h3> או נושא שתבחר</h3>`, html);
|
||||
});
|
||||
|
||||
it("should handle styles correctly", () => {
|
||||
let html = "";
|
||||
html = `<table border="5"> </table>`;
|
||||
assertResult(`<table border="5"> </table>`, html);
|
||||
|
||||
html = `<table border="xyz"> </table>`;
|
||||
assertResult(`<table> </table>`, html);
|
||||
|
||||
html = `<font color = "red"> Content </font>`;
|
||||
assertResult(`<font color="red"> Content </font>`, html);
|
||||
});
|
||||
|
||||
function assertResult(expectedResult: string, html: string): void {
|
||||
const result = KcSanitizer.sanitize(html, {
|
||||
DOMPurify: DOMPurify as any,
|
||||
htmlEntitiesDecode: decode
|
||||
});
|
||||
expect(result).toBe(expectedResult);
|
||||
}
|
||||
});
|
@ -1,7 +0,0 @@
|
||||
import type { MessageKey_defaultSet } from "keycloakify/login/i18n";
|
||||
|
||||
/* spell-checker: disable */
|
||||
const messages: Record<MessageKey_defaultSet, string> = null as any;
|
||||
/* spell-checker: enable */
|
||||
|
||||
export default messages;
|
@ -1,64 +0,0 @@
|
||||
import { i18nBuilder } from "keycloakify/login/i18n";
|
||||
import { assert, type Equals } from "tsafe/assert";
|
||||
import { Reflect } from "tsafe/Reflect";
|
||||
import type { I18n as I18n_notExtended } from "keycloakify/login/i18n";
|
||||
|
||||
const { useI18n, ofTypeI18n } = i18nBuilder
|
||||
.withThemeName<"my-theme-1" | "my-theme-2">()
|
||||
.withExtraLanguages({
|
||||
he: {
|
||||
label: "עברית",
|
||||
getMessages: () => import("./he")
|
||||
}
|
||||
})
|
||||
.withCustomTranslations({
|
||||
en: {
|
||||
myCustomKey1: "my-custom-key-1-en",
|
||||
myCustomKey2: {
|
||||
"my-theme-1": "my-theme-1-en",
|
||||
"my-theme-2": "my-theme-2-en"
|
||||
}
|
||||
},
|
||||
he: {
|
||||
myCustomKey1: "my-custom-key-1-he",
|
||||
myCustomKey2: {
|
||||
"my-theme-1": "my-theme-1-xx",
|
||||
"my-theme-2": "my-theme-2-xx"
|
||||
}
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
type I18n = typeof ofTypeI18n;
|
||||
|
||||
{
|
||||
const { i18n } = useI18n({ kcContext: Reflect<any>() });
|
||||
|
||||
assert<Equals<typeof i18n, I18n>>;
|
||||
}
|
||||
|
||||
{
|
||||
const x = (_i18n: I18n_notExtended) => {};
|
||||
|
||||
x(Reflect<I18n>());
|
||||
}
|
||||
|
||||
{
|
||||
const i18n = Reflect<I18n>();
|
||||
|
||||
const got = i18n.currentLanguage.languageTag;
|
||||
|
||||
type Expected =
|
||||
| import("keycloakify/login/i18n/messages_defaultSet/types").LanguageTag
|
||||
| "he";
|
||||
|
||||
assert<Equals<typeof got, Expected>>;
|
||||
}
|
||||
|
||||
{
|
||||
const i18n = Reflect<I18n>();
|
||||
|
||||
const node = i18n.msg("myCustomKey2");
|
||||
|
||||
assert<Equals<typeof node, JSX.Element>>;
|
||||
}
|
Reference in New Issue
Block a user