Compare commits

...

8 Commits

18 changed files with 270 additions and 78 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "keycloakify", "name": "keycloakify",
"version": "10.0.0-rc.19", "version": "10.0.0-rc.21",
"description": "Create Keycloak themes using React", "description": "Create Keycloak themes using React",
"repository": { "repository": {
"type": "git", "type": "git",
@ -65,7 +65,6 @@
"react": "*" "react": "*"
}, },
"dependencies": { "dependencies": {
"evt": "^2.5.7",
"minimal-polyfills": "^2.2.3", "minimal-polyfills": "^2.2.3",
"react-markdown": "^5.0.3", "react-markdown": "^5.0.3",
"tsafe": "^1.6.6" "tsafe": "^1.6.6"
@ -121,6 +120,7 @@
"vite": "^5.2.11", "vite": "^5.2.11",
"vitest": "^0.29.8", "vitest": "^0.29.8",
"yauzl": "^2.10.0", "yauzl": "^2.10.0",
"zod": "^3.17.10" "zod": "^3.17.10",
"evt": "^2.5.7"
} }
} }

View File

@ -29,17 +29,7 @@ out["messagesPerField"]= {
<#recover> <#recover>
<#assign doExistErrorOnUsernameOrPassword = true> <#assign doExistErrorOnUsernameOrPassword = true>
</#attempt> </#attempt>
<#if doExistErrorOnUsernameOrPassword> return <#if doExistErrorOnUsernameOrPassword>text<#else>undefined</#if>
return text;
<#else>
<#assign doExistMessageForField = "">
<#attempt>
<#assign doExistMessageForField = messagesPerField.exists('${fieldName}')>
<#recover>
<#assign doExistMessageForField = true>
</#attempt>
return <#if doExistMessageForField>text<#else>undefined</#if>;
</#if>
<#else> <#else>
<#assign doExistMessageForField = ""> <#assign doExistMessageForField = "">
<#attempt> <#attempt>
@ -107,22 +97,18 @@ out["messagesPerField"]= {
</#attempt> </#attempt>
<#if doExistErrorOnUsernameOrPassword> <#if doExistErrorOnUsernameOrPassword>
<#attempt> <#attempt>
return "${kcSanitize(msg('invalidUserMessage'))?no_esc}"; return decodeHtmlEntities("${msg('invalidUserMessage')?js_string}");
<#recover> <#recover>
return "Invalid username or password."; return "Invalid username or password.";
</#attempt> </#attempt>
<#else> <#else>
<#attempt> return "";
return "${messagesPerField.get('${fieldName}')?no_esc}";
<#recover>
return "";
</#attempt>
</#if> </#if>
<#else> <#else>
<#attempt> <#attempt>
return "${messagesPerField.get('${fieldName}')?no_esc}"; return decodeHtmlEntities("${messagesPerField.get('${fieldName}')?js_string}");
<#recover> <#recover>
return "invalid field"; return "Invalid field";
</#attempt> </#attempt>
</#if> </#if>
} }
@ -180,27 +166,36 @@ try {
} catch(error) { } } catch(error) { }
<#if profile?? && profile.attributes??> <#if profile?? && profile.attributes??>
out["__localizationReamlOverrides_userProfile"] = { out["lOCALIZATION_REALM_OVERRIDES_USER_PROFILE_PROPERTY_KEY_aaGLsPgGIdeeX"] = {
<#list profile.attributes as attribute> <#list profile.attributes as attribute>
<#if attribute.annotations?? && attribute.displayName??> <#if attribute.annotations?? && attribute.displayName??>
"${attribute.displayName}xx": "${advancedMsg(attribute.displayName)?no_esc}", "${attribute.displayName}": decodeHtmlEntities("${advancedMsg(attribute.displayName)?js_string}"),
</#if> </#if>
<#if attribute.annotations.inputHelperTextBefore??> <#if attribute.annotations.inputHelperTextBefore??>
"${attribute.annotations.inputHelperTextBefore}": "${advancedMsg(attribute.annotations.inputHelperTextBefore)?no_esc}", "${attribute.annotations.inputHelperTextBefore}": decodeHtmlEntities("${advancedMsg(attribute.annotations.inputHelperTextBefore)?js_string}"),
</#if> </#if>
<#if attribute.annotations.inputHelperTextAfter??> <#if attribute.annotations.inputHelperTextAfter??>
"${attribute.annotations.inputHelperTextAfter}": "${advancedMsg(attribute.annotations.inputHelperTextAfter)?no_esc}", "${attribute.annotations.inputHelperTextAfter}": decodeHtmlEntities("${advancedMsg(attribute.annotations.inputHelperTextAfter)?js_string}"),
</#if> </#if>
<#if attribute.annotations.inputTypePlaceholder??> <#if attribute.annotations.inputTypePlaceholder??>
"${attribute.annotations.inputTypePlaceholder}": "${advancedMsg(attribute.annotations.inputTypePlaceholder)?no_esc}", "${attribute.annotations.inputTypePlaceholder}": decodeHtmlEntities("${advancedMsg(attribute.annotations.inputTypePlaceholder)?js_string}"),
</#if> </#if>
</#list> </#list>
}; };
</#if> </#if>
return out; return out;
function decodeHtmlEntities(htmlStr){
var element = decodeHtmlEntities.element;
if (!element) {
element = document.createElement("textarea");
decodeHtmlEntities.element = element;
}
element.innerHTML = htmlStr;
return textarea.value;
}
})(); })();
<#function ftl_object_to_js_code_declaring_an_object object path> <#function ftl_object_to_js_code_declaring_an_object object path>

View File

@ -10,7 +10,8 @@ import {
type ThemeType, type ThemeType,
nameOfTheGlobal, nameOfTheGlobal,
basenameOfTheKeycloakifyResourcesDir, basenameOfTheKeycloakifyResourcesDir,
resources_common resources_common,
nameOfTheLocalizationRealmOverridesUserProfileProperty
} from "../../shared/constants"; } from "../../shared/constants";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath"; import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
@ -135,8 +136,11 @@ export function generateFtlFilesCodeFactory(params: {
.replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion) .replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion)
.replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType) .replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType)
.replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", themeName) .replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", themeName)
.replace("RESOURCES_COMMON_cLsLsMrtDkpVv", resources_common); .replace("RESOURCES_COMMON_cLsLsMrtDkpVv", resources_common)
.replace(
"lOCALIZATION_REALM_OVERRIDES_USER_PROFILE_PROPERTY_KEY_aaGLsPgGIdeeX",
nameOfTheLocalizationRealmOverridesUserProfileProperty
);
const ftlObjectToJsCodeDeclaringAnObjectPlaceholder = const ftlObjectToJsCodeDeclaringAnObjectPlaceholder =
'{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }'; '{ "x": "vIdLqMeOed9sdLdIdOxdK0d" }';

View File

@ -184,18 +184,47 @@ function toUTF16(codePoint: number): string {
} }
} }
// Escapes special characters and converts unicode to UTF-16 encoding // Escapes special characters for use in a .properties file
function escapeString(str: string): string { function escapeString(str: string): string {
let escapedStr = ""; let escapedStr = "";
for (const char of [...str]) { for (const char of [...str]) {
const codePoint = char.codePointAt(0); const codePoint = char.codePointAt(0);
if (!codePoint) continue; if (!codePoint) continue;
if (char === "'") {
escapedStr += "''"; // double single quotes switch (char) {
} else if (codePoint > 0x7f) { case "\n":
escapedStr += toUTF16(codePoint); // non-ascii characters escapedStr += "\\n";
} else { break;
escapedStr += char; case "\r":
escapedStr += "\\r";
break;
case "\t":
escapedStr += "\\t";
break;
case "\\":
escapedStr += "\\\\";
break;
case ":":
escapedStr += "\\:";
break;
case "=":
escapedStr += "\\=";
break;
case "#":
escapedStr += "\\#";
break;
case "!":
escapedStr += "\\!";
break;
case "'":
escapedStr += "''";
break;
default:
if (codePoint > 0x7f) {
escapedStr += toUTF16(codePoint); // Non-ASCII characters
} else {
escapedStr += char; // ASCII character needs no escape
}
} }
} }
return escapedStr; return escapedStr;

View File

@ -1,4 +1,6 @@
export const nameOfTheGlobal = "kcContext"; export const nameOfTheGlobal = "kcContext";
export const nameOfTheLocalizationRealmOverridesUserProfileProperty =
"__localizationRealmOverridesUserProfile";
export const keycloak_resources = "keycloak-resources"; export const keycloak_resources = "keycloak-resources";
export const resources_common = "resources-common"; export const resources_common = "resources-common";
export const lastKeycloakVersionWithAccountV1 = "21.1.2"; export const lastKeycloakVersionWithAccountV1 = "21.1.2";

View File

@ -17,17 +17,9 @@ export type BuildOptionsLike = {
assert<BuildOptions extends BuildOptionsLike ? true : false>(); assert<BuildOptions extends BuildOptionsLike ? true : false>();
export async function appBuild(params: { export async function appBuild(params: {
doSkipIfReactAppBuildDirExists: boolean;
buildOptions: BuildOptionsLike; buildOptions: BuildOptionsLike;
}): Promise<{ isAppBuildSuccess: boolean }> { }): Promise<{ isAppBuildSuccess: boolean }> {
const { doSkipIfReactAppBuildDirExists, buildOptions } = params; const { buildOptions } = params;
if (
doSkipIfReactAppBuildDirExists &&
fs.existsSync(buildOptions.reactAppBuildDirPath)
) {
return { isAppBuildSuccess: true };
}
const { bundler } = buildOptions; const { bundler } = buildOptions;
@ -82,6 +74,15 @@ export async function appBuild(params: {
) { ) {
return true; return true;
} }
if (
scriptValue.includes("ng") &&
scriptValue.includes("build")
) {
return true;
}
return false;
} }
) ?? []; ) ?? [];

View File

@ -84,7 +84,6 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
{ {
const { isAppBuildSuccess } = await appBuild({ const { isAppBuildSuccess } = await appBuild({
doSkipIfReactAppBuildDirExists: true,
buildOptions buildOptions
}); });
@ -432,7 +431,6 @@ export async function command(params: { cliCommandOptions: CliCommandOptions })
console.log(chalk.cyan("Detected changes in the theme. Rebuilding ...")); console.log(chalk.cyan("Detected changes in the theme. Rebuilding ..."));
const { isAppBuildSuccess } = await appBuild({ const { isAppBuildSuccess } = await appBuild({
doSkipIfReactAppBuildDirExists: false,
buildOptions buildOptions
}); });

View File

@ -4,12 +4,14 @@ import yauzl from "yauzl";
import stream from "stream"; import stream from "stream";
import { Deferred } from "evt/tools/Deferred"; import { Deferred } from "evt/tools/Deferred";
import { dirname as pathDirname, sep as pathSep } from "path"; import { dirname as pathDirname, sep as pathSep } from "path";
import { existsAsync } from "./fs.existsAsync";
export async function extractArchive(params: { export async function extractArchive(params: {
archiveFilePath: string; archiveFilePath: string;
onArchiveFile: (params: { onArchiveFile: (params: {
relativeFilePathInArchive: string; relativeFilePathInArchive: string;
readFile: () => Promise<Buffer>; readFile: () => Promise<Buffer>;
/** NOTE: Will create the directory if it does not exist */
writeFile: (params: { filePath: string; modifiedData?: Buffer }) => Promise<void>; writeFile: (params: { filePath: string; modifiedData?: Buffer }) => Promise<void>;
earlyExit: () => void; earlyExit: () => void;
}) => Promise<void>; }) => Promise<void>;
@ -42,7 +44,13 @@ export async function extractArchive(params: {
): Promise<void> => { ): Promise<void> => {
const { filePath, modifiedData } = params; const { filePath, modifiedData } = params;
await fs.mkdir(pathDirname(filePath), { recursive: true }); {
const dirPath = pathDirname(filePath);
if (!(await existsAsync(dirPath))) {
await fs.mkdir(dirPath, { recursive: true });
}
}
if (modifiedData !== undefined) { if (modifiedData !== undefined) {
await fs.writeFile(filePath, modifiedData); await fs.writeFile(filePath, modifiedData);

View File

@ -1,11 +1,9 @@
import "minimal-polyfills/Object.fromEntries"; import "minimal-polyfills/Object.fromEntries";
//NOTE for later: https://github.com/remarkjs/react-markdown/blob/236182ecf30bd89c1e5a7652acaf8d0bf81e6170/src/renderers.js#L7-L35
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import fallbackMessages from "./baseMessages/en"; import fallbackMessages from "./baseMessages/en";
import { getMessages } from "./baseMessages"; import { getMessages } from "./baseMessages";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import type { KcContext } from "../kcContext/KcContext"; import type { KcContext } from "../kcContext/KcContext";
import { Markdown } from "keycloakify/tools/Markdown";
export const fallbackLanguageTag = "en"; export const fallbackLanguageTag = "en";
@ -14,6 +12,7 @@ export type KcContextLike = {
currentLanguageTag: string; currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[]; supported: { languageTag: string; url: string; label: string }[];
}; };
__localizationRealmOverridesUserProfile: Record<string, string>;
}; };
assert<KcContext extends KcContextLike ? true : false>(); assert<KcContext extends KcContextLike ? true : false>();
@ -62,7 +61,7 @@ export type GenericI18n<MessageKey extends string> = {
/** /**
* Examples assuming currentLanguageTag === "en" * Examples assuming currentLanguageTag === "en"
* advancedMsg("${access-denied} foo bar") === msg("access-denied") + " foo bar" === "Access denied foo bar" * advancedMsg("${access-denied} foo bar") === msg("access-denied") + " foo bar" === "Access denied foo bar"
* advancedMsg("${not-a-message-key}") === advancedMsg(not-a-message-key") === "not-a-message-key" * advancedMsg("${not-a-message-key}") === advancedMsg("not-a-message-key") === "not-a-message-key"
*/ */
advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string; advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string;
}; };
@ -100,7 +99,8 @@ export function createUseI18n<ExtraMessageKey extends string = never>(extraMessa
...(await getMessages(currentLanguageTag)), ...(await getMessages(currentLanguageTag)),
...((keycloakifyExtraMessages as any)[currentLanguageTag] ?? {}), ...((keycloakifyExtraMessages as any)[currentLanguageTag] ?? {}),
...(extraMessages[currentLanguageTag] ?? {}) ...(extraMessages[currentLanguageTag] ?? {})
} as any } as any,
__localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile
}), }),
currentLanguageTag, currentLanguageTag,
getChangeLocalUrl: newLanguageTag => { getChangeLocalUrl: newLanguageTag => {
@ -130,11 +130,12 @@ export function createUseI18n<ExtraMessageKey extends string = never>(extraMessa
function createI18nTranslationFunctions<MessageKey extends string>(params: { function createI18nTranslationFunctions<MessageKey extends string>(params: {
fallbackMessages: Record<MessageKey, string>; fallbackMessages: Record<MessageKey, string>;
messages: Record<MessageKey, string>; messages: Record<MessageKey, string>;
__localizationRealmOverridesUserProfile: Record<string, string>;
}): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> { }): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const { fallbackMessages, messages } = params; const { fallbackMessages, messages /*__localizationRealmOverridesUserProfile*/ } = params;
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): string | JSX.Element | undefined { function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
const { key, args, doRenderMarkdown } = props; const { key, args, doRenderAsHtml } = props;
const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key]; const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key];
@ -163,23 +164,38 @@ function createI18nTranslationFunctions<MessageKey extends string>(params: {
return; return;
} }
messageWithArgsInjected = messageWithArgsInjected.replace(new RegExp(`\\{${i + startIndex}\\}`, "g"), arg); messageWithArgsInjected = messageWithArgsInjected.replace(
new RegExp(`\\{${i + startIndex}\\}`, "g"),
arg.replace(/</g, "&lt;").replace(/>/g, "&gt;")
);
}); });
return messageWithArgsInjected; return messageWithArgsInjected;
})(); })();
return doRenderMarkdown ? ( return doRenderAsHtml ? (
<Markdown allowDangerousHtml renderers={{ paragraph: "span" }}> <span
{messageWithArgsInjectedIfAny} // NOTE: The message is trusted. The arguments are not but are escaped.
</Markdown> dangerouslySetInnerHTML={{
__html: messageWithArgsInjectedIfAny
}}
/>
) : ( ) : (
messageWithArgsInjectedIfAny messageWithArgsInjectedIfAny
); );
} }
function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderMarkdown: boolean }): JSX.Element | string { function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string {
const { key, args, doRenderMarkdown } = props; const { key, args, doRenderAsHtml } = props;
// TODO:
/*
if( key in __localizationRealmOverridesUserProfile ){
}
*/
const match = key.match(/^\$\{([^{]+)\}$/); const match = key.match(/^\$\{([^{]+)\}$/);
@ -188,26 +204,26 @@ function createI18nTranslationFunctions<MessageKey extends string>(params: {
const out = resolveMsg({ const out = resolveMsg({
key: keyUnwrappedFromCurlyBraces, key: keyUnwrappedFromCurlyBraces,
args, args,
doRenderMarkdown doRenderAsHtml
}); });
return (out !== undefined ? out : doRenderMarkdown ? <span>{keyUnwrappedFromCurlyBraces}</span> : keyUnwrappedFromCurlyBraces) as any; return (out !== undefined ? out : doRenderAsHtml ? <span>{keyUnwrappedFromCurlyBraces}</span> : keyUnwrappedFromCurlyBraces) as any;
} }
return { return {
msgStr: (key, ...args) => resolveMsg({ key, args, doRenderMarkdown: false }) as string, msgStr: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: false }) as string,
msg: (key, ...args) => resolveMsg({ key, args, doRenderMarkdown: true }) as JSX.Element, msg: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: true }) as JSX.Element,
advancedMsg: (key, ...args) => advancedMsg: (key, ...args) =>
resolveMsgAdvanced({ resolveMsgAdvanced({
key, key,
args, args,
doRenderMarkdown: true doRenderAsHtml: true
}) as JSX.Element, }) as JSX.Element,
advancedMsgStr: (key, ...args) => advancedMsgStr: (key, ...args) =>
resolveMsgAdvanced({ resolveMsgAdvanced({
key, key,
args, args,
doRenderMarkdown: false doRenderAsHtml: false
}) as string }) as string
}; };
} }

View File

@ -1,4 +1,8 @@
import type { ThemeType, LoginThemePageId } from "keycloakify/bin/shared/constants"; import type {
ThemeType,
LoginThemePageId,
nameOfTheLocalizationRealmOverridesUserProfileProperty
} from "keycloakify/bin/shared/constants";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import type { Equals } from "tsafe"; import type { Equals } from "tsafe";
import type { MessageKey } from "../i18n/i18n"; import type { MessageKey } from "../i18n/i18n";
@ -140,6 +144,7 @@ export declare namespace KcContext {
tabId: string; tabId: string;
ssoLoginInOtherTabsUrl: string; ssoLoginInOtherTabsUrl: string;
}; };
__localizationRealmOverridesUserProfile: Record<string, string>;
}; };
export type SamlPostForm = Common & { export type SamlPostForm = Common & {
@ -750,3 +755,12 @@ export type PasswordPolicies = {
/** Whether the password can be the email address */ /** Whether the password can be the email address */
notEmail?: boolean; notEmail?: boolean;
}; };
assert<
KcContext.Common extends Record<
typeof nameOfTheLocalizationRealmOverridesUserProfileProperty,
unknown
>
? true
: false
>();

View File

@ -221,7 +221,8 @@ export const kcContextCommonMock: KcContext.Common = {
}, },
scripts: [], scripts: [],
isAppInitiatedAction: false, isAppInitiatedAction: false,
properties: {} properties: {},
__localizationRealmOverridesUserProfile: {}
}; };
const loginUrl = { const loginUrl = {

View File

@ -4,11 +4,13 @@ import { fallbackLanguageTag } from "keycloakify/login/i18n/i18n";
import { useConst } from "keycloakify/tools/useConst"; import { useConst } from "keycloakify/tools/useConst";
import { useConstCallback } from "keycloakify/tools/useConstCallback"; import { useConstCallback } from "keycloakify/tools/useConstCallback";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import { Evt } from "evt"; import {
import { useRerenderOnStateChange } from "evt/hooks/useRerenderOnStateChange"; createStatefulObservable,
useRerenderOnChange
} from "keycloakify/tools/StatefulObservable";
import { KcContext } from "../kcContext"; import { KcContext } from "../kcContext";
const evtTermsMarkdown = Evt.create<string | undefined>(undefined); const obsTermsMarkdown = createStatefulObservable<string | undefined>(() => undefined);
export type KcContextLike = { export type KcContextLike = {
pageId: string; pageId: string;
@ -45,15 +47,15 @@ export function useDownloadTerms(params: {
if (kcContext.pageId === "terms.ftl" || kcContext.termsAcceptanceRequired) { if (kcContext.pageId === "terms.ftl" || kcContext.termsAcceptanceRequired) {
downloadTermMarkdownMemoized( downloadTermMarkdownMemoized(
kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag
).then(thermMarkdown => (evtTermsMarkdown.state = thermMarkdown)); ).then(thermMarkdown => (obsTermsMarkdown.current = thermMarkdown));
} }
}, []); }, []);
} }
export function useTermsMarkdown() { export function useTermsMarkdown() {
useRerenderOnStateChange(evtTermsMarkdown); useRerenderOnChange(obsTermsMarkdown);
const termsMarkdown = evtTermsMarkdown.state; const termsMarkdown = obsTermsMarkdown.current;
return { termsMarkdown }; return { termsMarkdown };
} }

View File

@ -0,0 +1,16 @@
`StatefulObservable` is a construct that allow to avoid having to depend on [EVT](https://evt.land).
A `StatefulObservable` can be converted to an evt with:
```ts
import { statefulObservableToStatefulEvt } from "powerhooks/tools/StatefulObservable/statefulObservableToStatefulEvt";
const evtXyz = statefulObservableToStatefulEvt({
statefulObservable: $xyz
//Optionally you can pass a Ctx
});
```
WARNING: Unlike `StatefulEvt`, `StatefulObservable` do not post when we first attach.
If the current value was not yet evaluated `next()` is called on the initial value returned by the function that
returns it.

View File

@ -0,0 +1,58 @@
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
export type StatefulObservable<T> = {
current: T;
subscribe: (next: (data: T) => void) => Subscription;
};
export type Subscription = {
unsubscribe(): void;
};
export function createStatefulObservable<T>(
getInitialValue: () => T
): StatefulObservable<T> {
const nextFunctions: ((data: T) => void)[] = [];
const { get, set } = (() => {
let wrappedState: [T] | undefined = undefined;
function set(data: T) {
wrappedState = [data];
nextFunctions.forEach(next => next(data));
}
return {
get: () => {
if (wrappedState === undefined) {
set(getInitialValue());
assert(!is<undefined>(wrappedState));
}
return wrappedState[0];
},
set
};
})();
return Object.defineProperty(
{
current: null as any as T,
subscribe: (next: (data: T) => void) => {
nextFunctions.push(next);
return {
unsubscribe: () =>
nextFunctions.splice(nextFunctions.indexOf(next), 1)
};
}
},
"current",
{
enumerable: true,
get,
set
}
);
}

View File

@ -0,0 +1,2 @@
export * from "./useObservable";
export * from "./useRerenderOnChange";

View File

@ -0,0 +1,25 @@
import { useEffect } from "react";
import type { Subscription } from "../StatefulObservable";
/**
* Equivalent of https://docs.evt.land/api/react-hooks
*/
export function useObservable(
effect: (params: {
registerSubscription: (subscription: Subscription) => void;
}) => void,
deps: React.DependencyList
): void {
useEffect(() => {
const subscriptions: Subscription[] = [];
effect({
registerSubscription: subscription => subscriptions.push(subscription)
});
return () => {
subscriptions.forEach(subscription => subscription.unsubscribe());
subscriptions.length = 0;
};
}, deps);
}

View File

@ -0,0 +1,19 @@
import { useObservable } from "./useObservable";
import { useState } from "react";
import type { StatefulObservable } from "../StatefulObservable";
/**
* Equivalent of https://docs.evt.land/api/react-hooks
* */
export function useRerenderOnChange($: StatefulObservable<unknown>): void {
//NOTE: We use function in case the state is a function
const [, setCurrent] = useState(() => $.current);
useObservable(
({ registerSubscription }) => {
const subscription = $.subscribe(current => setCurrent(() => current));
registerSubscription(subscription);
},
[$]
);
}

View File

@ -0,0 +1,2 @@
export * from "./StatefulObservable";
export * from "./hooks";