Compare commits

..

50 Commits

Author SHA1 Message Date
e6993214ff Bump version 2024-09-25 10:26:46 +02:00
2f02a4379c Enable i18n in Single-Page account theme 2024-09-25 10:26:25 +02:00
b57d014e9a Bump version 2024-09-24 19:47:01 +02:00
f57f311aab Fix async io not awaited and don't crash if .ftl files does not exist for some reason 2024-09-24 19:47:01 +02:00
4f11415107 Merge pull request #667 from keycloakify/all-contributors/add-uchar
docs: add uchar as a contributor for test, and code
2024-09-23 05:00:23 +02:00
346fd7175f Only publish storybook if we are on main 2024-09-23 04:36:00 +02:00
7c02d77057 docs: update .all-contributorsrc [skip ci] 2024-09-23 02:31:26 +00:00
d3fd4b6bbf docs: update README.md [skip ci] 2024-09-23 02:31:25 +00:00
43ef527810 Bump version 2024-09-23 00:29:17 +02:00
a6032a1387 Merge pull request #653 from keycloakify/i18n_extraLanguages_and_perThemeVariantTranslations
Start implementing per theme variant translations and ability to add extra languages
2024-09-23 00:23:55 +02:00
23179cac53 Fix last bug 2024-09-23 00:19:34 +02:00
954c3319bb Fix bug in label resolution 2024-09-22 23:46:45 +02:00
eb6ec0275d Remove debug log 2024-09-22 22:53:31 +02:00
890f8bc2d5 Fix: Forget to create a dir before writing files 2024-09-22 22:53:13 +02:00
26b8dd9cda Improve intentionality 2024-09-22 22:48:31 +02:00
c07af8491c Complete statical parsing of withExtraLanguages 2024-09-22 22:46:56 +02:00
10d4da9fbf No need to escape since we sanitize 2024-09-22 22:18:24 +02:00
95e861099f Integrate kcSanitize 2024-09-22 20:41:18 +02:00
6dc51dfab3 Fix some bugs in the vendoring script 2024-09-22 20:21:07 +02:00
ddb0af1dcb Vendor dompurify, use isomorphic-dompurify only for tests 2024-09-22 20:12:11 +02:00
b6e9043d91 Reorganize kcSanitarize 2024-09-22 18:56:05 +02:00
7c553ee10d Restore package.json and yarn.lock 2024-09-22 18:29:29 +02:00
2a6b14adc6 Merge pull request #666 from uchar/fix/dangerouslySetInnerHTML
Fix/dangerously set inner html
2024-09-22 18:27:25 +02:00
159a5f60d0 Add missing scope in ftl template 2024-09-22 18:22:11 +02:00
08f03b3118 Merge branch 'main' into i18n_extraLanguages_and_perThemeVariantTranslations 2024-09-22 18:15:25 +02:00
f137960f96 Reneame useStylesAndScript to useInitialize 2024-09-22 18:12:46 +02:00
e5ab46727a Make the i18n API more type safe 2024-09-22 17:14:03 +02:00
8d2679b76e Progess in parsing of the extra languages provided by the user 2024-09-22 15:39:32 +02:00
b0b6b994ed Almost done, left to extract the extra language resources 2024-09-22 04:39:24 +02:00
bb163132fe Fix minor inconsistency 2024-09-21 23:42:59 +02:00
439bed2f24 Update account theme i18n.ts boilerplate 2024-09-21 23:41:08 +02:00
5a233d8878 Avoid too many types declaration indirections 2024-09-21 23:35:44 +02:00
20cdbb6185 Rename .create() by .build() for i18nBuilder 2024-09-21 23:21:15 +02:00
b3c4208e44 Rename i18nInitializer by i18nBuilder 2024-09-21 23:09:12 +02:00
8623037224 Various little adjustments relative to the new i18n API 2024-09-21 22:35:30 +02:00
e8d3d3d741 Automatically generate account i18n code 2024-09-21 21:44:14 +02:00
cc700f0ba0 Untrack account i18n, code will be generated automatically 2024-09-21 21:33:57 +02:00
801a5cce17 Enable to add label to extra message not in the default set 2024-09-21 21:33:04 +02:00
2a3ad58c18 Throw an error if providing translation for a language that is already supported 2024-09-21 18:17:43 +02:00
969744f4cb Complete runtime API implementation 2024-09-21 17:59:16 +02:00
40ebbdebeb Fix some type errors 2024-09-21 04:45:00 +02:00
eb64886dcf Generate LanguageTage.ts 2024-09-21 04:36:48 +02:00
81fc9d57bd remove async from sanitize 2024-09-18 18:37:17 +03:30
66b480f837 use textarea on client for decode 2024-09-18 11:13:49 +03:30
7e6a84ce19 Add more tests 2024-09-17 09:39:07 +03:30
68e7642827 Remove extra comment 2024-09-17 01:11:14 +03:30
b37c7ccc8a Merge with master 2024-09-17 01:01:45 +03:30
b7c9ba8ffd Merge branch 'main' of https://github.com/uchar/keycloakify into fix/dangerouslySetInnerHTML 2024-09-17 01:01:02 +03:30
c8a31c4b6a Add KCSantisizer 2024-09-17 00:56:46 +03:30
aad89a2001 Start implementing per theme variant translations and ability to add extra languages 2024-09-15 16:55:18 +02:00
70 changed files with 4203 additions and 1080 deletions

View File

@ -259,6 +259,16 @@
"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"
]
}
],
"contributorsPerLine": 7,

View File

@ -3,7 +3,6 @@ on:
push:
branches:
- main
- v10
pull_request:
branches:
- main
@ -36,21 +35,21 @@ jobs:
- run: npm run build
- run: npm run test
#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>"
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>"
check_if_version_upgraded:
name: Check if version upgrade
@ -129,7 +128,10 @@ jobs:
echo "Can't publish on NPM, You must first create a secret called NPM_TOKEN that contains your NPM auth token. https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets"
false
fi
EXTRA_ARGS="--tag v10"
EXTRA_ARGS=""
if [ "$IS_PRE_RELEASE" = "true" ]; then
EXTRA_ARGS="--tag next"
fi
npm publish $EXTRA_ARGS
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

2
.gitignore vendored
View File

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

View File

@ -132,6 +132,9 @@ 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>
</tr>
</tbody>
</table>

View File

@ -1,7 +1,7 @@
{
"name": "keycloakify",
"version": "10.1.5",
"description": "Create Keycloak themes using React",
"version": "11.1.0",
"description": "Framework to create custom Keycloak UIs",
"repository": {
"type": "git",
"url": "git://github.com/keycloakify/keycloakify.git"
@ -68,6 +68,7 @@
"@babel/core": "^7.24.5",
"@babel/generator": "^7.24.5",
"@babel/parser": "^7.24.5",
"@babel/preset-env": "7.24.8",
"@babel/types": "^7.24.5",
"@emotion/react": "^11.11.4",
"@octokit/rest": "^20.1.1",
@ -75,20 +76,27 @@
"@storybook/builder-webpack5": "^6.5.13",
"@storybook/manager-webpack5": "^6.5.13",
"@storybook/react": "^6.5.13",
"eslint-plugin-storybook": "^0.6.7",
"@types/babel__generator": "^7.6.4",
"@types/dompurify": "^2.0.0",
"@types/make-fetch-happen": "^10.0.1",
"@types/minimist": "^1.2.2",
"@types/node": "^18.15.3",
"@types/properties-parser": "^0.3.3",
"@types/react": "^18.0.35",
"@types/react-dom": "^18.0.11",
"@types/yauzl": "^2.10.3",
"@vercel/ncc": "^0.38.1",
"babel-loader": "9.1.3",
"chalk": "^4.1.2",
"cheerio": "1.0.0-rc.12",
"chokidar-cli": "^3.0.0",
"cli-select": "^1.1.2",
"dompurify": "^3.1.6",
"eslint-plugin-storybook": "^0.6.7",
"evt": "^2.5.7",
"html-entities": "^2.5.2",
"husky": "^4.3.8",
"isomorphic-dompurify": "^2.15.0",
"lint-staged": "^11.0.0",
"magic-string": "^0.30.7",
"make-fetch-happen": "^11.0.3",
@ -103,12 +111,13 @@
"termost": "^v0.12.1",
"tsc-alias": "^1.8.10",
"tss-react": "^4.9.10",
"tsx": "^4.15.5",
"typescript": "^4.9.4",
"vite": "^5.2.11",
"vitest": "^1.6.0",
"webpack": "5.93.0",
"webpack-cli": "5.1.4",
"yauzl": "^2.10.0",
"zod": "^3.17.10",
"evt": "^2.5.7",
"tsx": "^4.15.5"
"zod": "^3.17.10"
}
}

View File

@ -1,10 +1,4 @@
import * as child_process from "child_process";
import { run } from "./shared/run";
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 });
}

View File

@ -1,4 +1,3 @@
import * as child_process from "child_process";
import * as fs from "fs";
import { join } from "path";
import { assert } from "tsafe/assert";
@ -6,6 +5,8 @@ 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..."));
@ -88,6 +89,7 @@ import chalk from "chalk";
run(`npx tsc -p ${join("src", "tsconfig.json")}`);
run(`npx tsc-alias -p ${join("src", "tsconfig.json")}`);
vendorFrontendDependencies({ distDirPath: join(process.cwd(), "dist") });
if (fs.existsSync(join("dist", "vite-plugin", "index.original.js"))) {
fs.renameSync(
@ -164,12 +166,6 @@ import chalk from "chalk";
);
})();
function run(command: string) {
console.log(chalk.grey(`$ ${command}`));
child_process.execSync(command, { stdio: "inherit" });
}
function patchDeprecatedBufferApiUsage(filePath: string) {
const before = fs.readFileSync(filePath).toString("utf8");

View File

@ -0,0 +1,103 @@
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 ${pathRelative(process.cwd(), webpackConfigJsFilePath)}`
);
fs.readdirSync(webpackOutputDirPath)
.filter(fileBasename => !fileBasename.endsWith(".txt"))
.map(fileBasename => pathJoin(webpackOutputDirPath, fileBasename))
.forEach(bundleFilePath => {
assert(bundleFilePath.endsWith(".js"));
if (pathBasename(bundleFilePath) === "index.js") {
fs.renameSync(webpackOutputFilePath, filePath);
} else {
const bundleFileBasename = pathBasename(bundleFilePath);
assert(!extraBundleFileBasenames.has(bundleFileBasename));
extraBundleFileBasenames.add(bundleFileBasename);
fs.renameSync(
bundleFilePath,
pathJoin(pathDirname(filePath), bundleFileBasename)
);
}
});
fs.rmSync(webpackOutputDirPath, { recursive: true });
});
}

View File

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

View File

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

View File

@ -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,9 +47,3 @@ 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 });
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -87,15 +87,17 @@ 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,9 +168,6 @@ function decodeHtmlEntities(htmlStr){
) || (
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
areSamePath(path, ["realm"])
) || (
"smtpConfig" == key &&
are_same_path(path, ["realm"])
) || (
xKeycloakify.pageId == "error.ftl" &&
areSamePath(path, ["realm"]) &&

View File

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

View File

@ -26,17 +26,19 @@ export async function generateResources(params: {
rmSync(resourcesDirPath, { recursive: true });
}
await generateResourcesForMainTheme({
resourcesDirPath,
themeName,
buildContext
});
const { writeMessagePropertiesFilesForThemeVariant } =
await generateResourcesForMainTheme({
resourcesDirPath,
themeName,
buildContext
});
for (const themeVariantName of themeVariantNames) {
generateResourcesForThemeVariant({
resourcesDirPath,
themeName,
themeVariantName
themeVariantName,
writeMessagePropertiesFiles: writeMessagePropertiesFilesForThemeVariant
});
}
}

View File

@ -17,7 +17,10 @@ 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 {
generateMessageProperties,
type BuildContextLike as BuildContextLike_generateMessageProperties
} from "./generateMessageProperties";
import { rmSync } from "../../tools/fs.rmSync";
import { readThisNpmPackageVersion } from "../../tools/readThisNpmPackageVersion";
import {
@ -28,25 +31,32 @@ 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 & {
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_kcContextExclusionsFtlCode &
BuildContextLike_generateMessageProperties & {
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: {
buildContext: BuildContextLike;
themeName: string;
resourcesDirPath: string;
buildContext: BuildContextLike;
}): Promise<void> {
}): Promise<{
writeMessagePropertiesFilesForThemeVariant: (params: {
getMessageDirPath: (params: { themeType: ThemeType }) => string;
themeName: string;
}) => void;
}> {
const { themeName, resourcesDirPath, buildContext } = params;
const getThemeTypeDirPath = (params: { themeType: ThemeType | "email" }) => {
@ -54,6 +64,10 @@ export async function generateResourcesForMainTheme(params: {
return pathJoin(resourcesDirPath, "theme", themeName, themeType);
};
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;
@ -187,30 +201,27 @@ export async function generateResourcesForMainTheme(params: {
);
});
let languageTags: string[] | undefined = undefined;
i18n_messages_generation: {
if (isForAccountSpa) {
break i18n_messages_generation;
}
generateMessageProperties({
themeSrcDirPath: buildContext.themeSrcDirPath,
const wrap = generateMessageProperties({
buildContext,
themeType
}).forEach(({ languageTag, propertiesFileSource }) => {
const messagesDirPath = pathJoin(themeTypeDirPath, "messages");
});
fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), {
recursive: true
});
languageTags = wrap.languageTags;
const { writeMessagePropertiesFiles } = wrap;
const propertiesFilePath = pathJoin(
messagesDirPath,
`messages_${languageTag}.properties`
);
writeMessagePropertiesFilesByThemeType[themeType] =
writeMessagePropertiesFiles;
fs.writeFileSync(
propertiesFilePath,
Buffer.from(propertiesFileSource, "utf8")
);
writeMessagePropertiesFiles({
messageDirPath: pathJoin(themeTypeDirPath, "messages"),
themeName
});
}
@ -229,21 +240,73 @@ export async function generateResourcesForMainTheme(params: {
.toString("utf8")
.trim();
const messagesDirPath = pathJoin(accountUiDirPath, "messages");
const messageDirPath_defaults = pathJoin(accountUiDirPath, "messages");
if (!fs.existsSync(messagesDirPath)) {
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({ themeType: "account" }),
"messages"
);
transformCodebase({
srcDirPath: messagesDirPath,
destDirPath: pathJoin(
getThemeTypeDirPath({ themeType: "account" }),
"messages"
)
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: {
@ -281,7 +344,10 @@ export async function generateResourcesForMainTheme(params: {
...buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>
`${name}=\${env.${name}:${escapeStringForPropertiesFile(defaultValue)}}`
)
),
...(languageTags === undefined
? []
: [`locales=${languageTags.join(",")}`])
].join("\n\n"),
"utf8"
)
@ -338,4 +404,23 @@ export async function generateResourcesForMainTheme(params: {
getNewMetaInfKeycloakTheme: () => metaInfKeycloakThemes
});
}
return {
writeMessagePropertiesFilesForThemeVariant: ({
getMessageDirPath,
themeName
}) => {
objectEntries(writeMessagePropertiesFilesByThemeType).forEach(
([themeType, writeMessagePropertiesFiles]) => {
if (writeMessagePropertiesFiles === undefined) {
return;
}
writeMessagePropertiesFiles({
messageDirPath: getMessageDirPath({ themeType }),
themeName
});
}
);
}
};
}

View File

@ -1,27 +1,27 @@
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>();
import type { ThemeType } from "../../shared/constants";
export function generateResourcesForThemeVariant(params: {
resourcesDirPath: string;
themeName: string;
themeVariantName: string;
writeMessagePropertiesFiles: (params: {
getMessageDirPath: (params: { themeType: ThemeType }) => string;
themeName: string;
}) => void;
}) {
const { resourcesDirPath, themeName, themeVariantName } = params;
const { resourcesDirPath, themeName, themeVariantName, writeMessagePropertiesFiles } =
params;
const mainThemeDirPath = pathJoin(resourcesDirPath, "theme", themeName);
const themeVariantDirPath = pathJoin(mainThemeDirPath, "..", themeVariantName);
transformCodebase({
srcDirPath: mainThemeDirPath,
destDirPath: pathJoin(mainThemeDirPath, "..", themeVariantName),
destDirPath: themeVariantDirPath,
transformSourceCode: ({ fileRelativePath, sourceCode }) => {
if (
pathExtname(fileRelativePath) === ".ftl" &&
@ -67,4 +67,10 @@ export function generateResourcesForThemeVariant(params: {
return newMetaInfKeycloakTheme;
}
});
writeMessagePropertiesFiles({
getMessageDirPath: ({ themeType }) =>
pathJoin(themeVariantDirPath, themeType, "messages"),
themeName: themeVariantName
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { useEffect } from "react";
import { assert } from "keycloakify/tools/assert";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
import { KcContext } from "keycloakify/login/KcContext/KcContext";
import type { KcContext } from "keycloakify/login/KcContext";
export type KcContextLike = {
url: {
@ -10,34 +10,19 @@ export type KcContextLike = {
resourcesPath: string;
ssoLoginInOtherTabsUrl: string;
};
locale?: {
currentLanguageTag: string;
};
scripts: string[];
};
assert<keyof KcContextLike extends keyof KcContext ? true : false>();
assert<KcContext extends KcContextLike ? true : false>();
export function useStylesAndScripts(params: {
export function useInitialize(params: {
kcContext: KcContextLike;
doUseDefaultCss: boolean;
}) {
const { kcContext, doUseDefaultCss } = params;
const { url, locale, scripts } = kcContext;
useEffect(() => {
const { currentLanguageTag } = locale ?? {};
if (currentLanguageTag === undefined) {
return;
}
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
}, []);
const { url, scripts } = kcContext;
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
@ -69,9 +54,7 @@ export function useStylesAndScripts(params: {
textContent: `
import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";
checkCookiesAndSetTimer(
"${url.ssoLoginInOtherTabsUrl}"
);
checkCookiesAndSetTimer("${url.ssoLoginInOtherTabsUrl}");
`
}
]

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,341 @@
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 };
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,49 @@
import { useEffect, useState } from "react";
import { createGetI18n, type GenericI18n_noJsx, type KcContextLike, type MessageKey_defaultSet } from "./i18n";
import { GenericI18n } from "./GenericI18n";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { createGetI18n, type KcContextLike } from "../noJsx/getI18n";
import type { GenericI18n_noJsx } from "../noJsx/GenericI18n_noJsx";
import { Reflect } from "tsafe/Reflect";
import type { GenericI18n } from "./GenericI18n";
import type { LanguageTag as LanguageTag_defaultSet, MessageKey as MessageKey_defaultSet } from "../messages_defaultSet/types";
export type ReturnTypeOfCreateUseI18n<MessageKey_themeDefined extends string, LanguageTag_notInDefaultSet extends string> = {
useI18n: (params: { kcContext: KcContextLike }) => {
i18n: GenericI18n<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
};
ofTypeI18n: GenericI18n<MessageKey_defaultSet | MessageKey_themeDefined, LanguageTag_defaultSet | LanguageTag_notInDefaultSet>;
};
export { KcContextLike };
export function createUseI18n<
ThemeName extends string = string,
MessageKey_themeDefined extends string = never,
LanguageTag_notInDefaultSet extends string = never
>(params: {
extraLanguageTranslations: {
[languageTag in LanguageTag_notInDefaultSet]: {
label: string;
getMessages: () => Promise<{ default: Record<MessageKey_defaultSet, string> }>;
};
};
messagesByLanguageTag_themeDefined: Partial<{
[languageTag in LanguageTag_defaultSet | LanguageTag_notInDefaultSet]: {
[key in MessageKey_themeDefined]: string | Record<ThemeName, string>;
};
}>;
}): ReturnTypeOfCreateUseI18n<MessageKey_themeDefined, LanguageTag_notInDefaultSet> {
const { extraLanguageTranslations, messagesByLanguageTag_themeDefined } = params;
type LanguageTag = LanguageTag_defaultSet | LanguageTag_notInDefaultSet;
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>;
type I18n = GenericI18n<MessageKey, LanguageTag>;
type Result = { i18n: I18n };
const { withJsx } = (() => {
const cache = new WeakMap<GenericI18n_noJsx<MessageKey>, GenericI18n<MessageKey>>();
const cache = new WeakMap<GenericI18n_noJsx<MessageKey, LanguageTag>, GenericI18n<MessageKey, LanguageTag>>();
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
const { htmlString, msgKey } = params;
@ -19,13 +51,13 @@ export function createUseI18n<MessageKey_themeDefined extends string = never>(me
<div
data-kc-msg={msgKey}
dangerouslySetInnerHTML={{
__html: htmlString
__html: kcSanitize(htmlString)
}}
/>
);
}
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey>): I18n {
function withJsx(i18n_noJsx: GenericI18n_noJsx<MessageKey, LanguageTag>): I18n {
use_cache: {
const i18n = cache.get(i18n_noJsx);
@ -63,9 +95,9 @@ export function createUseI18n<MessageKey_themeDefined extends string = never>(me
(styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement);
}
const { getI18n } = createGetI18n(messagesByLanguageTag);
const { getI18n } = createGetI18n({ extraLanguageTranslations, messagesByLanguageTag_themeDefined });
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n } {
function useI18n(params: { kcContext: KcContextLike }): Result {
const { kcContext } = params;
const { i18n, prI18n_currentLanguage } = getI18n({ kcContext });

View File

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

View File

@ -3,6 +3,7 @@ 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";
@ -10,7 +11,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 { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n";
import type { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n";
import type { I18n } from "../i18n";
export type FormFieldError = {
@ -661,7 +662,7 @@ function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18
<span
key={0}
dangerouslySetInnerHTML={{
__html: errorMessageStr
__html: kcSanitize(errorMessageStr)
}}
/>
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

3
src/tools/vendor/dompurify.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import DOMPurify from "dompurify";
export { DOMPurify };

View File

@ -1,5 +1,7 @@
import { createUseI18n } from "../../dist/account";
import { i18nBuilder } from "../../dist/account";
export const { useI18n, ofTypeI18n } = createUseI18n({});
const { useI18n, ofTypeI18n } = i18nBuilder.build();
export type I18n = typeof ofTypeI18n;
type I18n = typeof ofTypeI18n;
export { useI18n, type I18n };

View File

@ -2,7 +2,6 @@ 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 = {

94
stories/login/gpt.md Normal file
View File

@ -0,0 +1,94 @@
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)

View File

@ -1,5 +1,7 @@
import { createUseI18n } from "../../dist/login";
import { i18nBuilder } from "../../dist/login";
export const { useI18n, ofTypeI18n } = createUseI18n({});
const { useI18n, ofTypeI18n } = i18nBuilder.build();
export type I18n = typeof ofTypeI18n;
type I18n = typeof ofTypeI18n;
export { useI18n, type I18n };

View File

@ -0,0 +1,141 @@
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&colon;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;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;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);
}
});

7
test/login/he.ts Normal file
View File

@ -0,0 +1,7 @@
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;

View File

@ -0,0 +1,64 @@
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>>;
}

1504
yarn.lock

File diff suppressed because it is too large Load Diff