From 0a964faf4239f79323fee5fa30aa674e78fd1b0a Mon Sep 17 00:00:00 2001 From: Nils Mehlhorn Date: Sun, 3 Jul 2022 18:01:28 +0200 Subject: [PATCH 1/2] feat: enable html preprocessing --- index.d.ts | 2 +- package-lock.json | 16 +++++++--------- package.json | 1 - src/Trans.js | 14 ++++++-------- test/i18n.js | 2 +- test/trans.render.spec.js | 35 +++++++++++++++++++---------------- ts4.1/index.d.ts | 2 +- 7 files changed, 35 insertions(+), 37 deletions(-) diff --git a/index.d.ts b/index.d.ts index 01233083..38c4f464 100644 --- a/index.d.ts +++ b/index.d.ts @@ -44,7 +44,7 @@ export interface TransProps parent?: string | React.ComponentType | null; // used in React.createElement if not null tOptions?: {}; values?: {}; - shouldUnescape?: boolean; + preprocessor?: (html: string) => string; t?: TFunction; } export function Trans(props: TransProps): React.ReactElement; diff --git a/package-lock.json b/package-lock.json index 2b1752af..c692b862 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.14.5", - "html-escaper": "^2.0.2", "html-parse-stringify": "^3.0.1" }, "devDependencies": { @@ -7888,7 +7887,8 @@ "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true }, "node_modules/html-parse-stringify": { "version": "3.0.1", @@ -17684,8 +17684,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", - "dev": true, - "requires": {} + "dev": true }, "acorn-walk": { "version": "6.2.0", @@ -18171,8 +18170,7 @@ "version": "7.0.0-bridge.0", "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz", "integrity": "sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==", - "dev": true, - "requires": {} + "dev": true }, "babel-eslint": { "version": "10.1.0", @@ -21345,7 +21343,8 @@ "html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true }, "html-parse-stringify": { "version": "3.0.1", @@ -22956,8 +22955,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "dev": true, - "requires": {} + "dev": true }, "jest-regex-util": { "version": "24.9.0", diff --git a/package.json b/package.json index ac522142..54bb989a 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ }, "dependencies": { "@babel/runtime": "^7.14.5", - "html-escaper": "^2.0.2", "html-parse-stringify": "^3.0.1" }, "devDependencies": { diff --git a/src/Trans.js b/src/Trans.js index 155804ac..164f6c90 100644 --- a/src/Trans.js +++ b/src/Trans.js @@ -1,6 +1,5 @@ import { useContext, isValidElement, cloneElement, createElement } from 'react'; import HTML from 'html-parse-stringify'; -import { unescape } from 'html-escaper'; import { getI18n, I18nContext, getDefaults } from './context'; import { warn, warnOnce } from './utils'; @@ -104,7 +103,7 @@ export function nodesToString(children, i18nOptions) { return stringNode; } -function renderNodes(children, targetString, i18n, i18nOptions, combinedTOpts, shouldUnescape) { +function renderNodes(children, targetString, i18n, i18nOptions, combinedTOpts, preprocessor) { if (targetString === '') return []; // check if contains tags we need to replace from html string to react nodes @@ -124,8 +123,7 @@ function renderNodes(children, targetString, i18n, i18nOptions, combinedTOpts, s childrenArray.forEach((child) => { if (typeof child === 'string') return; if (hasChildren(child)) getData(getChildren(child)); - else if (typeof child === 'object' && !isValidElement(child)) - Object.assign(data, child); + else if (typeof child === 'object' && !isValidElement(child)) Object.assign(data, child); }); } @@ -248,8 +246,8 @@ function renderNodes(children, targetString, i18n, i18nOptions, combinedTOpts, s } } else if (node.type === 'text') { const wrapTextNodes = i18nOptions.transWrapTextNodes; - const content = shouldUnescape - ? unescape(i18n.services.interpolator.interpolate(node.content, opts, i18n.language)) + const content = preprocessor + ? preprocessor(i18n.services.interpolator.interpolate(node.content, opts, i18n.language)) : i18n.services.interpolator.interpolate(node.content, opts, i18n.language); if (wrapTextNodes) { mem.push(createElement(wrapTextNodes, { key: `${node.name}-${i}` }, content)); @@ -285,7 +283,7 @@ export function Trans({ ns, i18n: i18nFromProps, t: tFromProps, - shouldUnescape, + preprocessor, ...additionalProps }) { const { i18n: i18nFromContext, defaultNS: defaultNSFromContext } = useContext(I18nContext) || {}; @@ -332,7 +330,7 @@ export function Trans({ i18n, reactI18nextOptions, combinedTOpts, - shouldUnescape, + preprocessor, ); // allows user to pass `null` to `parent` diff --git a/test/i18n.js b/test/i18n.js index 5ce3f156..3cf84005 100644 --- a/test/i18n.js +++ b/test/i18n.js @@ -42,7 +42,7 @@ i18n.init({ transTest3: 'Result should be a clickable link <0 href="https://www.google.com">Google', transTest3_overwrite: 'Result should be a clickable link <0 href="https://www.google.com">Google', - transTestEscapedHtml: 'Escaped html should unescape correctly <0>< &>.', + transTestPreprocessedHtml: 'Html should be preprocessed: <0>&.', testTransWithCtx: 'Go <1>there.', testTransWithCtx_home: 'Go <1>home.', deepPath: { diff --git a/test/trans.render.spec.js b/test/trans.render.spec.js index 3ff288fa..84800842 100644 --- a/test/trans.render.spec.js +++ b/test/trans.render.spec.js @@ -623,25 +623,28 @@ describe('trans does ignore user defined values when parsing', () => { }); }); -describe('trans should allow escaped html', () => { +it('trans should allow preprocessing html', () => { + const preprocessor = (html) => html.replace('&', '&'); const TestComponent = () => ( - ]} shouldUnescape /> + ]} + preprocessor={preprocessor} + /> ); - it('should unescape <   & > to < SPACE & >', () => { - const { container } = render(); - expect(container.firstChild).toMatchInlineSnapshot(` -
- Escaped html should unescape correctly - - < &> - - . -
- `); - }); + const { container } = render(); + expect(container.firstChild).toMatchInlineSnapshot(` +
+ Html should be preprocessed: + + & + + . +
+ `); }); it('transSupportBasicHtmlNodes: false should not keep the name of simple nodes', () => { diff --git a/ts4.1/index.d.ts b/ts4.1/index.d.ts index 29530ef4..63cc698d 100644 --- a/ts4.1/index.d.ts +++ b/ts4.1/index.d.ts @@ -274,7 +274,7 @@ export type TransProps< parent?: string | React.ComponentType | null; // used in React.createElement if not null tOptions?: {}; values?: {}; - shouldUnescape?: boolean; + preprocessor?: (html: string) => string; t?: TFunction; }; From 5f935c309be11c4e16ae5ee4762392f8f5fe4161 Mon Sep 17 00:00:00 2001 From: Nils Mehlhorn Date: Tue, 5 Jul 2022 12:52:14 +0200 Subject: [PATCH 2/2] allow custom unescape override --- index.d.ts | 2 +- src/Trans.js | 12 +++++--- src/context.js | 2 ++ src/unescape.js | 18 +++++++++++ test/i18n.js | 3 +- test/trans.render.spec.js | 64 +++++++++++++++++++++++++++------------ ts4.1/index.d.ts | 2 +- 7 files changed, 76 insertions(+), 27 deletions(-) create mode 100644 src/unescape.js diff --git a/index.d.ts b/index.d.ts index 38c4f464..01233083 100644 --- a/index.d.ts +++ b/index.d.ts @@ -44,7 +44,7 @@ export interface TransProps parent?: string | React.ComponentType | null; // used in React.createElement if not null tOptions?: {}; values?: {}; - preprocessor?: (html: string) => string; + shouldUnescape?: boolean; t?: TFunction; } export function Trans(props: TransProps): React.ReactElement; diff --git a/src/Trans.js b/src/Trans.js index 164f6c90..86fc443b 100644 --- a/src/Trans.js +++ b/src/Trans.js @@ -103,7 +103,7 @@ export function nodesToString(children, i18nOptions) { return stringNode; } -function renderNodes(children, targetString, i18n, i18nOptions, combinedTOpts, preprocessor) { +function renderNodes(children, targetString, i18n, i18nOptions, combinedTOpts, shouldUnescape) { if (targetString === '') return []; // check if contains tags we need to replace from html string to react nodes @@ -246,8 +246,10 @@ function renderNodes(children, targetString, i18n, i18nOptions, combinedTOpts, p } } else if (node.type === 'text') { const wrapTextNodes = i18nOptions.transWrapTextNodes; - const content = preprocessor - ? preprocessor(i18n.services.interpolator.interpolate(node.content, opts, i18n.language)) + const content = shouldUnescape + ? i18nOptions.unescape( + i18n.services.interpolator.interpolate(node.content, opts, i18n.language), + ) : i18n.services.interpolator.interpolate(node.content, opts, i18n.language); if (wrapTextNodes) { mem.push(createElement(wrapTextNodes, { key: `${node.name}-${i}` }, content)); @@ -283,7 +285,7 @@ export function Trans({ ns, i18n: i18nFromProps, t: tFromProps, - preprocessor, + shouldUnescape, ...additionalProps }) { const { i18n: i18nFromContext, defaultNS: defaultNSFromContext } = useContext(I18nContext) || {}; @@ -330,7 +332,7 @@ export function Trans({ i18n, reactI18nextOptions, combinedTOpts, - preprocessor, + shouldUnescape, ); // allows user to pass `null` to `parent` diff --git a/src/context.js b/src/context.js index 5efd1457..d33664ad 100644 --- a/src/context.js +++ b/src/context.js @@ -1,4 +1,5 @@ import { createContext } from 'react'; +import { unescape } from './unescape'; let defaultOptions = { bindI18n: 'languageChanged', @@ -10,6 +11,7 @@ let defaultOptions = { transKeepBasicHtmlNodesFor: ['br', 'strong', 'i', 'p'], // hashTransKey: key => key // calculate a key for Trans component based on defaultValue useSuspense: true, + unescape, }; let i18nInstance; diff --git a/src/unescape.js b/src/unescape.js new file mode 100644 index 00000000..1b01a2b1 --- /dev/null +++ b/src/unescape.js @@ -0,0 +1,18 @@ +const matchHtmlEntity = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g; + +const htmlEntities = { + '&': '&', + '&': '&', + '<': '<', + '<': '<', + '>': '>', + '>': '>', + ''': "'", + ''': "'", + '"': '"', + '"': '"', +}; + +const unescapeHtmlEntity = (m) => htmlEntities[m]; + +export const unescape = (text) => text.replace(matchHtmlEntity, unescapeHtmlEntity); diff --git a/test/i18n.js b/test/i18n.js index 3cf84005..d1237627 100644 --- a/test/i18n.js +++ b/test/i18n.js @@ -42,7 +42,8 @@ i18n.init({ transTest3: 'Result should be a clickable link <0 href="https://www.google.com">Google', transTest3_overwrite: 'Result should be a clickable link <0 href="https://www.google.com">Google', - transTestPreprocessedHtml: 'Html should be preprocessed: <0>&.', + transTestEscapedHtml: 'Escaped html should unescape correctly <0>< &>.', + transTestCustomUnescape: 'Text should be passed through custom unescape <0>­', testTransWithCtx: 'Go <1>there.', testTransWithCtx_home: 'Go <1>home.', deepPath: { diff --git a/test/trans.render.spec.js b/test/trans.render.spec.js index 84800842..ec9cb915 100644 --- a/test/trans.render.spec.js +++ b/test/trans.render.spec.js @@ -623,28 +623,54 @@ describe('trans does ignore user defined values when parsing', () => { }); }); -it('trans should allow preprocessing html', () => { - const preprocessor = (html) => html.replace('&', '&'); +describe('trans should allow escaped html', () => { const TestComponent = () => ( - ]} - preprocessor={preprocessor} - /> + ]} shouldUnescape /> ); - const { container } = render(); - expect(container.firstChild).toMatchInlineSnapshot(` -
- Html should be preprocessed: - - & - - . -
- `); + it('should unescape <   & > to < SPACE & >', () => { + const { container } = render(); + expect(container.firstChild).toMatchInlineSnapshot(` +
+ Escaped html should unescape correctly + + < &> + + . +
+ `); + }); +}); + +describe('trans with custom unescape', () => { + let orgValue; + beforeAll(() => { + orgValue = i18n.options.react.unescape; + i18n.options.react.unescape = (text) => text.replace('­', '\u00AD'); + }); + + afterAll(() => { + i18n.options.react.unescape = orgValue; + }); + + it('should allow unescape override', () => { + const TestComponent = () => ( + ]} shouldUnescape /> + ); + const { container } = render(); + expect(container.firstChild).toMatchInlineSnapshot(` +
+ Text should be passed through custom unescape + + \u00AD + +
+ `); + }); }); it('transSupportBasicHtmlNodes: false should not keep the name of simple nodes', () => { diff --git a/ts4.1/index.d.ts b/ts4.1/index.d.ts index 63cc698d..29530ef4 100644 --- a/ts4.1/index.d.ts +++ b/ts4.1/index.d.ts @@ -274,7 +274,7 @@ export type TransProps< parent?: string | React.ComponentType | null; // used in React.createElement if not null tOptions?: {}; values?: {}; - preprocessor?: (html: string) => string; + shouldUnescape?: boolean; t?: TFunction; };