From d6a63d3920567939778285675abb9ffe171a8593 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 26 Nov 2025 12:34:30 +0100 Subject: [PATCH 1/5] add library to en/decode HTML entities --- package.json | 2 ++ yarn.lock | 5 +++++ 2 files changed, 7 insertions(+) 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/yarn.lock b/yarn.lock index 52af1ffc..022500e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3079,6 +3079,11 @@ dependencies: "@types/unist" "*" +"@types/he@^1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@types/he/-/he-1.2.3.tgz#c33ca3096f30cbd5d68d78211572de3f9adff75a" + integrity sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA== + "@types/hoist-non-react-statics@^3.3.0": version "3.3.6" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz#6bba74383cdab98e8db4e20ce5b4a6b98caed010" From 329461b63f9b1f9b3dc8cd3286ac8fb35a90cdc6 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 26 Nov 2025 12:44:40 +0100 Subject: [PATCH 2/5] add decode utility method and use it as an option for the reduceToText functionality --- CHANGELOG.md | 1 + src/common/index.ts | 8 +++-- src/common/utils/reduceToText.tsx | 29 +++++++++++++++++-- .../TextReducer/TextReducer.stories.tsx | 3 +- src/components/TextReducer/TextReducer.tsx | 18 +++++++++--- 5 files changed, 50 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bccc72d..59ec733b 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 char - 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/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..740c2265 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,30 @@ 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 some error tolerance even in strict mode + text = text + .split(" ") + .map((value) => { + try { + return utils.decodeHtmlEntities(value, { ...decodeDefaultOptions }); + } 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.tsx b/src/components/TextReducer/TextReducer.tsx index 16a89ff2..544bdd4d 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 used 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 ? ( Date: Wed, 26 Nov 2025 12:51:30 +0100 Subject: [PATCH 3/5] fix overwriting default options for decoding and add tests --- src/common/utils/reduceToText.tsx | 5 ++- .../TextReducer/TextReducer.test.tsx | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/components/TextReducer/TextReducer.test.tsx diff --git a/src/common/utils/reduceToText.tsx b/src/common/utils/reduceToText.tsx index 740c2265..92802f54 100644 --- a/src/common/utils/reduceToText.tsx +++ b/src/common/utils/reduceToText.tsx @@ -58,7 +58,10 @@ export const reduceToText: ReduceToTextFuncType = (input, options) => { .split(" ") .map((value) => { try { - return utils.decodeHtmlEntities(value, { ...decodeDefaultOptions }); + return utils.decodeHtmlEntities(value, { + ...decodeDefaultOptions, + ...options?.decodeHtmlEntitiesOptions, + }); } catch { decodeErrors++; return value; diff --git a/src/components/TextReducer/TextReducer.test.tsx b/src/components/TextReducer/TextReducer.test.tsx new file mode 100644 index 00000000..1aa4bdce --- /dev/null +++ b/src/components/TextReducer/TextReducer.test.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { render } from "@testing-library/react"; + +import "@testing-library/jest-dom"; + +import { Markdown, TextReducer } from "./../../"; +import { Default as TextReducerStory } from "./TextReducer.stories"; + +describe("TextReducer", () => { + it("should display encoded HTML entities by default if they are used in the transformed markup", () => { + const { queryByText } = render(); + expect(queryByText("'entities' & "quotes"", { exact: false })).not.toBeNull(); + expect(queryByText(`'entities' & "quotes"`, { exact: false })).toBeNull(); + }); + it("should not display encoded HTML entities if `decodeHtmlEntities` is enabled", () => { + const { queryByText } = render(); + expect(queryByText("'entities' & "quotes"", { exact: false })).toBeNull(); + expect(queryByText(`'entities' & "quotes"`, { exact: false })).not.toBeNull(); + }); + it("should only decode if correct encoded HTML entities are found (strict mode)", () => { + const { queryByText } = render( + + && foo&bar + + ); + expect(queryByText("& & foo&bar", { exact: false })).not.toBeNull(); + expect(queryByText("& & foo&bar", { exact: false })).toBeNull(); + }); + it("should allow decoding non-strict encoded HTML entities", () => { + const { queryByText } = render( + + && foo&bar + + ); + expect(queryByText("& & foo&bar", { exact: false })).toBeNull(); + expect(queryByText("& & foo&bar", { exact: false })).not.toBeNull(); + }); +}); From 4f9c67b77a1aa47402a38004d06a2b4894a47fb7 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 26 Nov 2025 13:01:23 +0100 Subject: [PATCH 4/5] fix some typos --- CHANGELOG.md | 2 +- src/common/utils/reduceToText.tsx | 2 +- src/components/TextReducer/TextReducer.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59ec733b..68b1dfa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +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 char + - `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/src/common/utils/reduceToText.tsx b/src/common/utils/reduceToText.tsx index 92802f54..d929ff41 100644 --- a/src/common/utils/reduceToText.tsx +++ b/src/common/utils/reduceToText.tsx @@ -53,7 +53,7 @@ export const reduceToText: ReduceToTextFuncType = (input, options) => { strict: true, } as DecodeHtmlEntitiesOptions; let decodeErrors = 0; - // we decode in pieces to some error tolerance even in strict mode + // we decode in pieces to apply some error tolerance even in strict mode text = text .split(" ") .map((value) => { diff --git a/src/components/TextReducer/TextReducer.tsx b/src/components/TextReducer/TextReducer.tsx index 544bdd4d..b73d2ee9 100644 --- a/src/components/TextReducer/TextReducer.tsx +++ b/src/components/TextReducer/TextReducer.tsx @@ -30,9 +30,9 @@ export interface TextReducerProps extends Pick */ decodeHtmlEntities?: boolean; /** - * Set the options used to decode the html entities, if `decodeHtmlEntities` is enabled. + * 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 used we use `{ isAttributeValue: true, strict: true }` as default value. + * If not set we use `{ isAttributeValue: true, strict: true }` as default value. */ decodeHtmlEntitiesOptions?: DecodeHtmlEntitiesOptions; } From be337c359d66279483b5d9c97de26711acf181ff Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Thu, 27 Nov 2025 14:30:48 +0100 Subject: [PATCH 5/5] Clean up test --- .../TextReducer/TextReducer.test.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/components/TextReducer/TextReducer.test.tsx b/src/components/TextReducer/TextReducer.test.tsx index 1aa4bdce..691adf2d 100644 --- a/src/components/TextReducer/TextReducer.test.tsx +++ b/src/components/TextReducer/TextReducer.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render } from "@testing-library/react"; +import {render, RenderResult} from "@testing-library/react"; import "@testing-library/jest-dom"; @@ -7,15 +7,21 @@ 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(); - expect(queryByText("'entities' & "quotes"", { exact: false })).not.toBeNull(); - expect(queryByText(`'entities' & "quotes"`, { exact: false })).toBeNull(); + textMustExist(queryByText, "'entities' & "quotes""); + textMustNotExist(queryByText, `'entities' & "quotes"`); }); it("should not display encoded HTML entities if `decodeHtmlEntities` is enabled", () => { const { queryByText } = render(); - expect(queryByText("'entities' & "quotes"", { exact: false })).toBeNull(); - expect(queryByText(`'entities' & "quotes"`, { exact: false })).not.toBeNull(); + textMustNotExist(queryByText, "'entities' & "quotes""); + textMustExist(queryByText, `'entities' & "quotes"`); }); it("should only decode if correct encoded HTML entities are found (strict mode)", () => { const { queryByText } = render( @@ -23,8 +29,8 @@ describe("TextReducer", () => { && foo&bar ); - expect(queryByText("& & foo&bar", { exact: false })).not.toBeNull(); - expect(queryByText("& & foo&bar", { exact: false })).toBeNull(); + textMustExist(queryByText, "& & foo&bar"); + textMustNotExist(queryByText, "& & foo&bar"); }); it("should allow decoding non-strict encoded HTML entities", () => { const { queryByText } = render( @@ -32,7 +38,7 @@ describe("TextReducer", () => { && foo&bar ); - expect(queryByText("& & foo&bar", { exact: false })).toBeNull(); - expect(queryByText("& & foo&bar", { exact: false })).not.toBeNull(); + textMustNotExist(queryByText, "& & foo&bar"); + textMustExist(queryByText, "& & foo&bar"); }); });