diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1bccc72d..68b1dfa5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -54,6 +54,7 @@ This is a major release, and it might be not compatible with your current usage
- `colorCalculateDistance()`: calculates the difference between 2 colors using the simple CIE76 formula
- `textToColorHash()`: calculates a color from a text string
- `reduceToText`: shrinks HTML content and React elements to plain text, used for ``
+ - `decodeHtmlEntities`: decode a string of HTML text, map HTML entities back to UTF-8 chars
- SCSS color functions
- `eccgui-color-var`: returns a var of a custom property used for palette color
- `eccgui-color-mix`: mix 2 colors in `srgb`, works with all types of color values and CSS custom properties
diff --git a/package.json b/package.json
index ab732b94..1a671be9 100644
--- a/package.json
+++ b/package.json
@@ -87,6 +87,7 @@
"codemirror": "^6.0.1",
"color": "^4.2.3",
"compute-scroll-into-view": "^3.1.1",
+ "he": "^1.2.0",
"jshint": "^2.13.6",
"lodash": "^4.17.21",
"n3": "^1.25.1",
@@ -134,6 +135,7 @@
"@testing-library/react": "^12.1.5",
"@types/codemirror": "^5.60.15",
"@types/color": "^3.0.6",
+ "@types/he": "^1.2.3",
"@types/jest": "^29.5.14",
"@types/jshint": "^2.12.4",
"@types/lodash": "^4.17.16",
diff --git a/src/common/index.ts b/src/common/index.ts
index 161a07f9..ab989fa3 100644
--- a/src/common/index.ts
+++ b/src/common/index.ts
@@ -1,3 +1,5 @@
+import { decode } from "he";
+
import { invisibleZeroWidthCharacters } from "./utils/characters";
import { colorCalculateDistance } from "./utils/colorCalculateDistance";
import decideContrastColorValue from "./utils/colorDecideContrastvalue";
@@ -6,7 +8,8 @@ import getColorConfiguration from "./utils/getColorConfiguration";
import { getScrollParent } from "./utils/getScrollParent";
import { getGlobalVar, setGlobalVar } from "./utils/globalVars";
import { openInNewTab } from "./utils/openInNewTab";
-import { reduceToText } from "./utils/reduceToText"
+import { reduceToText } from "./utils/reduceToText";
+export type { DecodeOptions as DecodeHtmlEntitiesOptions } from "he";
export type { IntentTypes as IntentBaseTypes } from "./Intent";
export const utils = {
@@ -20,5 +23,6 @@ export const utils = {
getScrollParent,
getEnabledColorsFromPalette,
textToColorHash,
- reduceToText
+ reduceToText,
+ decodeHtmlEntities: decode,
};
diff --git a/src/common/utils/reduceToText.tsx b/src/common/utils/reduceToText.tsx
index c8cf938b..d929ff41 100644
--- a/src/common/utils/reduceToText.tsx
+++ b/src/common/utils/reduceToText.tsx
@@ -3,6 +3,7 @@ import { renderToString } from "react-dom/server";
import * as ReactIs from "react-is";
import { TextReducerProps } from "./../../components/TextReducer/TextReducer";
+import { DecodeHtmlEntitiesOptions, utils } from "./../";
export interface ReduceToTextFuncType {
(
@@ -10,12 +11,12 @@ export interface ReduceToTextFuncType {
* Component or text to reduce HTML markup content to plain text.
*/
input: React.ReactNode | React.ReactNode[] | string,
- options?: Pick
+ options?: Pick
): string;
}
export const reduceToText: ReduceToTextFuncType = (input, options) => {
- const { maxNodes, maxLength } = options || {};
+ const { maxNodes, maxLength, decodeHtmlEntities } = options || {};
const content: React.ReactNode | React.ReactNode[] = input;
let nodeCount = 0;
@@ -46,6 +47,33 @@ export const reduceToText: ReduceToTextFuncType = (input, options) => {
// Basic HTML cleanup
text = text.replace(/<[^\s][^>]*>/g, "").replace(/\n/g, " ");
+ if (decodeHtmlEntities) {
+ const decodeDefaultOptions = {
+ isAttributeValue: true,
+ strict: true,
+ } as DecodeHtmlEntitiesOptions;
+ let decodeErrors = 0;
+ // we decode in pieces to apply some error tolerance even in strict mode
+ text = text
+ .split(" ")
+ .map((value) => {
+ try {
+ return utils.decodeHtmlEntities(value, {
+ ...decodeDefaultOptions,
+ ...options?.decodeHtmlEntitiesOptions,
+ });
+ } catch {
+ decodeErrors++;
+ return value;
+ }
+ })
+ .join(" ");
+ if (decodeErrors > 0) {
+ // eslint-disable-next-line no-console
+ console.warn(`${decodeErrors} parse error(s) for decodeHtmlEntities, return un-decoded text`, text);
+ }
+ }
+
if (typeof maxLength === "number") {
text = text.slice(0, maxLength);
}
diff --git a/src/components/TextReducer/TextReducer.stories.tsx b/src/components/TextReducer/TextReducer.stories.tsx
index b8125046..48aa0468 100644
--- a/src/components/TextReducer/TextReducer.stories.tsx
+++ b/src/components/TextReducer/TextReducer.stories.tsx
@@ -18,8 +18,9 @@ Default.args = {
,
"Simple text with URL http://example.com/ that should not get parsed.",
"a < b to test equations in text like b > a.",
+ `Something with a "quote" in it.`,
<>
- {`* This\n* is\n* a\n* list\n\nwritten in Markdown.`}
+ {`* This\n* is\n* a\n* list\n\nwritten in Markdown\n* containing a few HTML 'entities' & "quotes".`}
Block with sub elements
diff --git a/src/components/TextReducer/TextReducer.test.tsx b/src/components/TextReducer/TextReducer.test.tsx
new file mode 100644
index 00000000..691adf2d
--- /dev/null
+++ b/src/components/TextReducer/TextReducer.test.tsx
@@ -0,0 +1,44 @@
+import React from "react";
+import {render, RenderResult} from "@testing-library/react";
+
+import "@testing-library/jest-dom";
+
+import { Markdown, TextReducer } from "./../../";
+import { Default as TextReducerStory } from "./TextReducer.stories";
+
+describe("TextReducer", () => {
+ const textMustExist = (queryByText: RenderResult["queryByText"], text: string) => {
+ expect(queryByText(text, { exact: false })).not.toBeNull();
+ }
+ const textMustNotExist = (queryByText: RenderResult["queryByText"], text: string) => {
+ expect(queryByText(text, { exact: false })).toBeNull();
+ }
+ it("should display encoded HTML entities by default if they are used in the transformed markup", () => {
+ const { queryByText } = render();
+ textMustExist(queryByText, "'entities' & "quotes"");
+ textMustNotExist(queryByText, `'entities' & "quotes"`);
+ });
+ it("should not display encoded HTML entities if `decodeHtmlEntities` is enabled", () => {
+ const { queryByText } = render();
+ textMustNotExist(queryByText, "'entities' & "quotes"");
+ textMustExist(queryByText, `'entities' & "quotes"`);
+ });
+ it("should only decode if correct encoded HTML entities are found (strict mode)", () => {
+ const { queryByText } = render(
+
+ && foo&bar
+
+ );
+ textMustExist(queryByText, "& & foo&bar");
+ textMustNotExist(queryByText, "& & foo&bar");
+ });
+ it("should allow decoding non-strict encoded HTML entities", () => {
+ const { queryByText } = render(
+
+ && foo&bar
+
+ );
+ textMustNotExist(queryByText, "& & foo&bar");
+ textMustExist(queryByText, "& & foo&bar");
+ });
+});
diff --git a/src/components/TextReducer/TextReducer.tsx b/src/components/TextReducer/TextReducer.tsx
index 16a89ff2..b73d2ee9 100644
--- a/src/components/TextReducer/TextReducer.tsx
+++ b/src/components/TextReducer/TextReducer.tsx
@@ -1,6 +1,6 @@
import React from "react";
-import { reduceToText } from "../../common/utils/reduceToText";
+import { DecodeHtmlEntitiesOptions, utils } from "../../common";
import { CLASSPREFIX as eccgui } from "../../configuration/constants";
import { OverflowText, OverflowTextProps } from "./../Typography";
@@ -24,6 +24,17 @@ export interface TextReducerProps extends Pick
* Specify more `OverflowText` properties used when `useOverflowTextWrapper` is set to `true`.
*/
overflowTextProps?: Omit;
+ /**
+ * If you transform HTML markup to text then the result could contain HTML entity encoded strings.
+ * By enabling this option they are decoded back to it's original char.
+ */
+ decodeHtmlEntities?: boolean;
+ /**
+ * Set the options used to decode the HTML entities, if `decodeHtmlEntities` is enabled.
+ * Internally we use `he` library, see their [documentation on decode options](https://www.npmjs.com/package/he#hedecodehtml-options).
+ * If not set we use `{ isAttributeValue: true, strict: true }` as default value.
+ */
+ decodeHtmlEntitiesOptions?: DecodeHtmlEntitiesOptions;
}
/**
@@ -32,16 +43,15 @@ export interface TextReducerProps extends Pick
*/
export const TextReducer = ({
children,
- maxNodes,
- maxLength,
useOverflowTextWrapper,
overflowTextProps,
+ ...reduceToTextOptions
}: TextReducerProps) => {
if (typeof children === "undefined") {
return <>>;
}
- const shrinkedContent = reduceToText(children, { maxLength, maxNodes });
+ const shrinkedContent = utils.reduceToText(children, reduceToTextOptions);
return useOverflowTextWrapper ? (